diff --git a/controller/channel-test.go b/controller/channel-test.go index bdd67d27a90d..520dccbbc0ed 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -467,6 +467,10 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, } } info.SetEstimatePromptTokens(usage.PromptTokens) + if updatedPriceData, applied := ratio_setting.ApplyModelTierPricing(info.OriginModelName, priceData, usage.PromptTokens); applied { + priceData = updatedPriceData + info.PriceData = updatedPriceData + } quota := 0 if !priceData.UsePrice { diff --git a/controller/option.go b/controller/option.go index ecb1e25e8677..955293e52ec9 100644 --- a/controller/option.go +++ b/controller/option.go @@ -19,6 +19,7 @@ import ( var completionRatioMetaOptionKeys = []string{ "ModelPrice", "ModelRatio", + "ModelTierPricing", "CompletionRatio", "CacheRatio", "CreateCacheRatio", @@ -233,6 +234,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "ModelTierPricing": + err = ratio_setting.UpdateModelTierPricingByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "模型阶梯定价设置失败: " + err.Error(), + }) + return + } case "ModelRequestRateLimitGroup": err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) if err != nil { diff --git a/controller/option_test.go b/controller/option_test.go new file mode 100644 index 000000000000..5c041ba989ab --- /dev/null +++ b/controller/option_test.go @@ -0,0 +1,36 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/stretchr/testify/require" +) + +func TestBuildCompletionRatioMetaValueIncludesTierOnlyModels(t *testing.T) { + metaJSON := buildCompletionRatioMetaValue(map[string]string{ + "ModelTierPricing": `{ + "gpt-5": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "input_price": 2, + "completion_price": 16 + } + ] + } +}`, + }) + + meta := make(map[string]ratio_setting.CompletionRatioInfo) + require.NoError(t, common.UnmarshalJsonStr(metaJSON, &meta)) + + info, ok := meta["gpt-5"] + require.True(t, ok) + require.True(t, info.Locked) + require.Equal(t, 8.0, info.Ratio) +} diff --git a/model/option.go b/model/option.go index 967fa0aa6708..baca453b3a91 100644 --- a/model/option.go +++ b/model/option.go @@ -130,6 +130,7 @@ func InitOptionMap() { common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["ModelTierPricing"] = ratio_setting.ModelTierPricing2JSONString() common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() @@ -472,6 +473,8 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateCompletionRatioByJSONString(value) case "ModelPrice": err = ratio_setting.UpdateModelPriceByJSONString(value) + case "ModelTierPricing": + err = ratio_setting.UpdateModelTierPricingByJSONString(value) case "CacheRatio": err = ratio_setting.UpdateCacheRatioByJSONString(value) case "CreateCacheRatio": diff --git a/relay/helper/price.go b/relay/helper/price.go index e9c3b4637633..11d015b22e9f 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -61,6 +61,10 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var audioRatio float64 var audioCompletionRatio float64 var freeModel bool + priceData := types.PriceData{ + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + } if !usePrice { preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) if meta.MaxTokens != 0 { @@ -69,7 +73,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var success bool var matchName string modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) - if !success { + if !success && !ratio_setting.HasEnabledModelTierPricing(info.OriginModelName) { acceptUnsetRatio := false if info.UserSetting.AcceptUnsetRatioModel { acceptUnsetRatio = true @@ -87,13 +91,29 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) - ratio := modelRatio * groupRatioInfo.GroupRatio + priceData.ModelRatio = modelRatio + priceData.CompletionRatio = completionRatio + priceData.CacheRatio = cacheRatio + priceData.CacheCreationRatio = cacheCreationRatio + priceData.CacheCreation5mRatio = cacheCreationRatio5m + priceData.CacheCreation1hRatio = cacheCreationRatio1h + priceData.ImageRatio = imageRatio + priceData.AudioRatio = audioRatio + priceData.AudioCompletionRatio = audioCompletionRatio + if updatedPriceData, applied := ratio_setting.ApplyModelTierPricing(info.OriginModelName, priceData, promptTokens); applied { + priceData = updatedPriceData + } + modelRatio = priceData.ModelRatio + completionRatio = priceData.CompletionRatio + cacheRatio = priceData.CacheRatio + ratio := priceData.ModelRatio * groupRatioInfo.GroupRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + priceData.ModelPrice = modelPrice } // check if free model pre-consume is disabled @@ -115,22 +135,18 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } } - priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - ModelRatio: modelRatio, - CompletionRatio: completionRatio, - GroupRatioInfo: groupRatioInfo, - UsePrice: usePrice, - CacheRatio: cacheRatio, - ImageRatio: imageRatio, - AudioRatio: audioRatio, - AudioCompletionRatio: audioCompletionRatio, - CacheCreationRatio: cacheCreationRatio, - CacheCreation5mRatio: cacheCreationRatio5m, - CacheCreation1hRatio: cacheCreationRatio1h, - QuotaToPreConsume: preConsumedQuota, - } + priceData.FreeModel = freeModel + priceData.ModelPrice = modelPrice + priceData.ModelRatio = modelRatio + priceData.CompletionRatio = completionRatio + priceData.CacheRatio = cacheRatio + priceData.ImageRatio = imageRatio + priceData.AudioRatio = audioRatio + priceData.AudioCompletionRatio = audioCompletionRatio + priceData.CacheCreationRatio = cacheCreationRatio + priceData.CacheCreation5mRatio = cacheCreationRatio5m + priceData.CacheCreation1hRatio = cacheCreationRatio1h + priceData.QuotaToPreConsume = preConsumedQuota if common.DebugEnabled { println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) @@ -201,13 +217,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } func ContainPriceOrRatio(modelName string) bool { - _, ok := ratio_setting.GetModelPrice(modelName, false) - if ok { - return true - } - _, ok, _ = ratio_setting.GetModelRatio(modelName) - if ok { - return true - } - return false + _, _, exist := ratio_setting.GetModelRatioOrPrice(modelName) + return exist } diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go new file mode 100644 index 000000000000..b55ab87ca18e --- /dev/null +++ b/relay/helper/price_test.go @@ -0,0 +1,60 @@ +package helper + +import ( + "net/http/httptest" + "testing" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestModelPriceHelperAllowsTierOnlyPricing(t *testing.T) { + gin.SetMode(gin.TestMode) + + require.NoError(t, ratio_setting.UpdateModelTierPricingByJSONString(`{ + "tier-only-gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`)) + t.Cleanup(func() { + require.NoError(t, ratio_setting.UpdateModelTierPricingByJSONString("{}")) + }) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + info := &relaycommon.RelayInfo{ + OriginModelName: "tier-only-gemini-3.1-pro-preview", + UsingGroup: "default", + UserGroup: "default", + } + + priceData, err := ModelPriceHelper(ctx, info, 250000, &types.TokenCountMeta{}) + require.NoError(t, err) + require.False(t, priceData.UsePrice) + require.Equal(t, 2.0, priceData.ModelRatio) + require.Equal(t, 4.5, priceData.CompletionRatio) + require.Equal(t, 0.1, priceData.CacheRatio) + require.NotNil(t, priceData.TierPricing) + require.Equal(t, 1, priceData.TierPricing.TierIndex) + require.Equal(t, 250000, priceData.TierPricing.BasisValue) +} diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 75e6fb1d4908..5ee6ac88db34 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -77,6 +77,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m appendBillingInfo(relayInfo, other) appendParamOverrideInfo(relayInfo, other) appendStreamStatus(relayInfo, other) + appendTierPricingInfo(relayInfo, other) return other } @@ -114,10 +115,30 @@ func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]inter other["stream_status"] = streamInfo } +func appendTierPricingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if relayInfo == nil || other == nil || relayInfo.PriceData.TierPricing == nil { + return + } + tierInfo := relayInfo.PriceData.TierPricing + other["tier_pricing_enabled"] = tierInfo.Enabled + other["tier_basis"] = tierInfo.Basis + other["tier_index"] = tierInfo.TierIndex + other["tier_min_tokens"] = tierInfo.MinTokens + if tierInfo.MaxTokens != nil { + other["tier_max_tokens"] = *tierInfo.MaxTokens + } + other["tier_basis_value"] = tierInfo.BasisValue +} + func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { if relayInfo == nil || other == nil { return } + if relayInfo.PriceData.UsePrice { + other["billing_quota_type"] = 1 + } else { + other["billing_quota_type"] = 0 + } // billing_source: "wallet" or "subscription" if relayInfo.BillingSource != "" { other["billing_source"] = relayInfo.BillingSource @@ -256,6 +277,7 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price other := make(map[string]interface{}) other["model_price"] = priceData.ModelPrice other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio + other["billing_quota_type"] = 1 if priceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio } diff --git a/service/log_info_generate_test.go b/service/log_info_generate_test.go new file mode 100644 index 000000000000..f0c92d8c1238 --- /dev/null +++ b/service/log_info_generate_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "net/http/httptest" + "testing" + "time" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGenerateTextOtherInfoIncludesBillingQuotaTypeForTierPricing(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + now := time.Now() + + relayInfo := &relaycommon.RelayInfo{ + StartTime: now, + FirstResponseTime: now, + ChannelMeta: &relaycommon.ChannelMeta{}, + PriceData: types.PriceData{ + UsePrice: false, + TierPricing: &types.TierPricingMeta{ + Enabled: true, + Basis: "prompt_tokens", + TierIndex: 1, + MinTokens: 200000, + BasisValue: 200321, + }, + }, + } + + other := GenerateTextOtherInfo(ctx, relayInfo, 2, 1, 4.5, 0, 0.1, 0, -1) + + require.Equal(t, 0, other["billing_quota_type"]) + require.Equal(t, true, other["tier_pricing_enabled"]) + require.Equal(t, 1, other["tier_index"]) + require.Equal(t, 200321, other["tier_basis_value"]) +} + +func TestGenerateTextOtherInfoIncludesBillingQuotaTypeForPerCallPricing(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + now := time.Now() + + relayInfo := &relaycommon.RelayInfo{ + StartTime: now, + FirstResponseTime: now, + ChannelMeta: &relaycommon.ChannelMeta{}, + PriceData: types.PriceData{ + UsePrice: true, + }, + } + + other := GenerateTextOtherInfo(ctx, relayInfo, 0, 1, 0, 0, 0, 0.02, -1) + + require.Equal(t, 1, other["billing_quota_type"]) +} diff --git a/service/quota.go b/service/quota.go index 9dc84ab4be9f..be96f9fd4f1b 100644 --- a/service/quota.go +++ b/service/quota.go @@ -30,13 +30,16 @@ type TokenDetails struct { } type QuotaInfo struct { - InputDetails TokenDetails - OutputDetails TokenDetails - ModelName string - UsePrice bool - ModelPrice float64 - ModelRatio float64 - GroupRatio float64 + InputDetails TokenDetails + OutputDetails TokenDetails + ModelName string + UsePrice bool + ModelPrice float64 + ModelRatio float64 + GroupRatio float64 + CompletionRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 } func hasCustomModelRatio(modelName string, currentRatio float64) bool { @@ -57,9 +60,9 @@ func calculateAudioQuota(info QuotaInfo) int { return int(quota.IntPart()) } - completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName)) - audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName)) - audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName)) + completionRatio := decimal.NewFromFloat(info.CompletionRatio) + audioRatio := decimal.NewFromFloat(info.AudioRatio) + audioCompletionRatio := decimal.NewFromFloat(info.AudioCompletionRatio) groupRatio := decimal.NewFromFloat(info.GroupRatio) modelRatio := decimal.NewFromFloat(info.ModelRatio) @@ -106,7 +109,11 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag audioInputTokens := usage.InputTokenDetails.AudioTokens audioOutTokens := usage.OutputTokenDetails.AudioTokens groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) - modelRatio, _, _ := ratio_setting.GetModelRatio(modelName) + applyTierPricingToRelayInfo(relayInfo, usage.InputTokens) + modelRatio := relayInfo.PriceData.ModelRatio + if modelRatio == 0 && relayInfo.PriceData.TierPricing == nil { + modelRatio, _, _ = ratio_setting.GetModelRatio(modelName) + } autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup) if exists { @@ -130,10 +137,13 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: modelName, - UsePrice: relayInfo.UsePrice, - ModelRatio: modelRatio, - GroupRatio: actualGroupRatio, + ModelName: modelName, + UsePrice: relayInfo.PriceData.UsePrice, + ModelRatio: modelRatio, + GroupRatio: actualGroupRatio, + CompletionRatio: relayInfo.PriceData.CompletionRatio, + AudioRatio: relayInfo.PriceData.AudioRatio, + AudioCompletionRatio: relayInfo.PriceData.AudioCompletionRatio, } quota := calculateAudioQuota(quotaInfo) @@ -158,6 +168,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod usage *dto.RealtimeUsage, extraContent string) { useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + applyTierPricingToRelayInfo(relayInfo, usage.InputTokens) textInputTokens := usage.InputTokenDetails.TextTokens textOutTokens := usage.OutputTokenDetails.TextTokens @@ -165,9 +176,15 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod audioOutTokens := usage.OutputTokenDetails.AudioTokens tokenName := ctx.GetString("token_name") - completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName)) - audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) - audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName)) + completionRatioValue := relayInfo.PriceData.CompletionRatio + if completionRatioValue == 0 && relayInfo.PriceData.TierPricing == nil { + completionRatioValue = ratio_setting.GetCompletionRatio(modelName) + } + audioRatioValue := relayInfo.PriceData.AudioRatio + audioCompletionRatioValue := relayInfo.PriceData.AudioCompletionRatio + completionRatio := decimal.NewFromFloat(completionRatioValue) + audioRatio := decimal.NewFromFloat(audioRatioValue) + audioCompletionRatio := decimal.NewFromFloat(audioCompletionRatioValue) modelRatio := relayInfo.PriceData.ModelRatio groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio @@ -183,10 +200,13 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: modelName, - UsePrice: usePrice, - ModelRatio: modelRatio, - GroupRatio: groupRatio, + ModelName: modelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + CompletionRatio: completionRatioValue, + AudioRatio: audioRatioValue, + AudioCompletionRatio: audioCompletionRatioValue, } quota := calculateAudioQuota(quotaInfo) @@ -259,6 +279,7 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + applyTierPricingToRelayInfo(relayInfo, usage.PromptTokens) textInputTokens := usage.PromptTokensDetails.TextTokens textOutTokens := usage.CompletionTokenDetails.TextTokens @@ -266,9 +287,15 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u audioOutTokens := usage.CompletionTokenDetails.AudioTokens tokenName := ctx.GetString("token_name") - completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName)) - audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) - audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName)) + completionRatioValue := relayInfo.PriceData.CompletionRatio + if completionRatioValue == 0 && relayInfo.PriceData.TierPricing == nil { + completionRatioValue = ratio_setting.GetCompletionRatio(relayInfo.OriginModelName) + } + audioRatioValue := relayInfo.PriceData.AudioRatio + audioCompletionRatioValue := relayInfo.PriceData.AudioCompletionRatio + completionRatio := decimal.NewFromFloat(completionRatioValue) + audioRatio := decimal.NewFromFloat(audioRatioValue) + audioCompletionRatio := decimal.NewFromFloat(audioCompletionRatioValue) modelRatio := relayInfo.PriceData.ModelRatio groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio @@ -284,10 +311,13 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: relayInfo.OriginModelName, - UsePrice: usePrice, - ModelRatio: modelRatio, - GroupRatio: groupRatio, + ModelName: relayInfo.OriginModelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + CompletionRatio: completionRatioValue, + AudioRatio: audioRatioValue, + AudioCompletionRatio: audioCompletionRatioValue, } quota := calculateAudioQuota(quotaInfo) diff --git a/service/text_quota.go b/service/text_quota.go index 8caee8f28799..cfdec0a3af09 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -78,6 +78,15 @@ func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *d } func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary { + if usage == nil { + usage = &dto.Usage{ + PromptTokens: relayInfo.GetEstimatePromptTokens(), + CompletionTokens: 0, + TotalTokens: relayInfo.GetEstimatePromptTokens(), + } + } + applyTierPricingToRelayInfo(relayInfo, usage.PromptTokens) + summary := textQuotaSummary{ ModelName: relayInfo.OriginModelName, TokenName: ctx.GetString("token_name"), @@ -95,14 +104,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf } summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic" - if usage == nil { - usage = &dto.Usage{ - PromptTokens: relayInfo.GetEstimatePromptTokens(), - CompletionTokens: 0, - TotalTokens: relayInfo.GetEstimatePromptTokens(), - } - } - summary.PromptTokens = usage.PromptTokens summary.CompletionTokens = usage.CompletionTokens summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens diff --git a/service/text_quota_test.go b/service/text_quota_test.go index e995de17ae8b..1b21b05326f6 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -278,6 +279,74 @@ func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheCreationFromPromptBill require.Equal(t, 3012, summary.Quota) } +func TestCalculateTextQuotaSummaryReappliesTierPricingOnFinalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + require.NoError(t, ratio_setting.UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`)) + t.Cleanup(func() { + require.NoError(t, ratio_setting.UpdateModelTierPricingByJSONString("{}")) + }) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "google/gemini-3.1-pro-preview", + PriceData: types.PriceData{ + ModelRatio: 1, + CompletionRatio: 6, + CacheRatio: 0.1, + CacheCreationRatio: 1.25, + CacheCreation5mRatio: 1.25, + CacheCreation1hRatio: 2, + ImageRatio: 0.5, + AudioRatio: 0.75, + AudioCompletionRatio: 2, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 2.4, + }, + }, + StartTime: time.Now(), + } + + usage := &dto.Usage{ + PromptTokens: 200000, + CompletionTokens: 1000, + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, usage) + + require.Equal(t, 2.0, summary.ModelRatio) + require.Equal(t, 4.5, summary.CompletionRatio) + require.Equal(t, 0.1, summary.CacheRatio) + require.Equal(t, 981600, summary.Quota) + require.NotNil(t, relayInfo.PriceData.TierPricing) + require.Equal(t, 1, relayInfo.PriceData.TierPricing.TierIndex) + require.Equal(t, 200000, relayInfo.PriceData.TierPricing.BasisValue) + require.Equal(t, 0.5, relayInfo.PriceData.ImageRatio) + require.Equal(t, 0.75, relayInfo.PriceData.AudioRatio) + require.Equal(t, 2.0, relayInfo.PriceData.AudioCompletionRatio) +} + func TestCalculateTextQuotaSummaryKeepsPrePRClaudeOpenRouterBilling(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/service/tier_pricing.go b/service/tier_pricing.go new file mode 100644 index 000000000000..293ce7bff13d --- /dev/null +++ b/service/tier_pricing.go @@ -0,0 +1,18 @@ +package service + +import ( + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +func applyTierPricingToRelayInfo(relayInfo *relaycommon.RelayInfo, promptTokens int) { + if relayInfo == nil || relayInfo.PriceData.UsePrice { + return + } + if promptTokens < 0 { + promptTokens = 0 + } + if updatedPriceData, applied := ratio_setting.ApplyModelTierPricing(relayInfo.OriginModelName, relayInfo.PriceData, promptTokens); applied { + relayInfo.PriceData = updatedPriceData + } +} diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index c88216fcb015..512b35e70cf4 100644 --- a/setting/ratio_setting/exposed_cache.go +++ b/setting/ratio_setting/exposed_cache.go @@ -47,6 +47,7 @@ func GetExposedData() gin.H { "cache_ratio": GetCacheRatioCopy(), "create_cache_ratio": GetCreateCacheRatioCopy(), "model_price": GetModelPriceCopy(), + "model_tier_pricing": GetModelTierPricingCopy(), } exposedData.Store(&exposedCache{ data: newData, diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 62fc8b3e10db..65ceb165c451 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -343,6 +343,7 @@ func InitRatioSettings() { imageRatioMap.AddAll(defaultImageRatio) audioRatioMap.AddAll(defaultAudioRatio) audioCompletionRatioMap.AddAll(defaultAudioCompletionRatio) + modelTierPricingMap.Clear() } func GetModelPriceMap() map[string]float64 { @@ -730,5 +731,8 @@ func GetModelRatioOrPrice(model string) (float64, bool, bool) { // price or rati if success { return modelRatio, false, true } + if tierRatio, ok := GetModelTierPricingBaseRatio(model); ok { + return tierRatio, false, true + } return 37.5, false, false } diff --git a/setting/ratio_setting/tier_pricing.go b/setting/ratio_setting/tier_pricing.go new file mode 100644 index 000000000000..69b0b2c9ccc9 --- /dev/null +++ b/setting/ratio_setting/tier_pricing.go @@ -0,0 +1,229 @@ +package ratio_setting + +import ( + "fmt" + "sort" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" +) + +const TierPricingBasisPromptTokens = "prompt_tokens" + +var modelTierPricingMap = types.NewRWMap[string, types.ModelTierPricingConfig]() + +func cloneMaxTokens(maxTokens *int) *int { + if maxTokens == nil { + return nil + } + value := *maxTokens + return &value +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneTier(tier types.ModelTierPricingTier) types.ModelTierPricingTier { + cloned := tier + cloned.MaxTokens = cloneMaxTokens(tier.MaxTokens) + cloned.CacheReadPrice = cloneFloat64(tier.CacheReadPrice) + return cloned +} + +func cloneTierPricingConfig(config types.ModelTierPricingConfig) types.ModelTierPricingConfig { + cloned := config + cloned.Tiers = make([]types.ModelTierPricingTier, 0, len(config.Tiers)) + for _, tier := range config.Tiers { + cloned.Tiers = append(cloned.Tiers, cloneTier(tier)) + } + return cloned +} + +func normalizeTierPricingConfig(config types.ModelTierPricingConfig) (types.ModelTierPricingConfig, error) { + normalized := cloneTierPricingConfig(config) + normalized.Basis = strings.TrimSpace(normalized.Basis) + if normalized.Basis == "" { + normalized.Basis = TierPricingBasisPromptTokens + } + if normalized.Basis != TierPricingBasisPromptTokens { + return types.ModelTierPricingConfig{}, fmt.Errorf("unsupported tier pricing basis: %s", normalized.Basis) + } + if len(normalized.Tiers) == 0 { + if normalized.Enabled { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier pricing requires at least one tier") + } + return normalized, nil + } + + sort.Slice(normalized.Tiers, func(i, j int) bool { + return normalized.Tiers[i].MinTokens < normalized.Tiers[j].MinTokens + }) + + for index, tier := range normalized.Tiers { + if tier.MinTokens < 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d min_tokens cannot be negative", index) + } + if tier.InputPrice < 0 || tier.CompletionPrice < 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d prices cannot be negative", index) + } + if tier.CacheReadPrice != nil && *tier.CacheReadPrice < 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d cache_read_price cannot be negative", index) + } + if tier.InputPrice == 0 && tier.CompletionPrice != 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d cannot set completion price when input price is 0", index) + } + if tier.InputPrice == 0 && tier.CacheReadPrice != nil && *tier.CacheReadPrice != 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d cannot set cache price when input price is 0", index) + } + if index == 0 && tier.MinTokens != 0 { + return types.ModelTierPricingConfig{}, fmt.Errorf("first tier must start from 0") + } + if tier.MaxTokens == nil { + if index != len(normalized.Tiers)-1 { + return types.ModelTierPricingConfig{}, fmt.Errorf("only the final tier may omit max_tokens") + } + continue + } + if *tier.MaxTokens <= tier.MinTokens { + return types.ModelTierPricingConfig{}, fmt.Errorf("tier %d max_tokens must be greater than min_tokens", index) + } + if index == len(normalized.Tiers)-1 { + return types.ModelTierPricingConfig{}, fmt.Errorf("final tier must omit max_tokens") + } + nextTier := normalized.Tiers[index+1] + if nextTier.MinTokens != *tier.MaxTokens { + return types.ModelTierPricingConfig{}, fmt.Errorf("tiers must be contiguous without gaps or overlaps") + } + } + + return normalized, nil +} + +func ModelTierPricing2JSONString() string { + return modelTierPricingMap.MarshalJSONString() +} + +func UpdateModelTierPricingByJSONString(jsonStr string) error { + if strings.TrimSpace(jsonStr) == "" { + jsonStr = "{}" + } + + parsed := make(map[string]types.ModelTierPricingConfig) + if err := common.UnmarshalJsonStr(jsonStr, &parsed); err != nil { + return err + } + + normalized := make(map[string]types.ModelTierPricingConfig, len(parsed)) + for modelName, config := range parsed { + normalizedConfig, err := normalizeTierPricingConfig(config) + if err != nil { + return fmt.Errorf("model %s: %w", modelName, err) + } + completionRatioInfo := GetCompletionRatioInfo(modelName) + if completionRatioInfo.Locked && (normalizedConfig.Enabled || len(normalizedConfig.Tiers) > 0) { + return fmt.Errorf("model %s: tier pricing is not supported because completion ratio is locked", modelName) + } + normalized[modelName] = normalizedConfig + } + + jsonBytes, err := common.Marshal(normalized) + if err != nil { + return err + } + return types.LoadFromJsonStringWithCallback(modelTierPricingMap, string(jsonBytes), InvalidateExposedDataCache) +} + +func GetModelTierPricing(modelName string) (types.ModelTierPricingConfig, bool) { + if config, ok := modelTierPricingMap.Get(modelName); ok { + return cloneTierPricingConfig(config), true + } + formattedModelName := FormatMatchingModelName(modelName) + if formattedModelName != modelName { + if config, ok := modelTierPricingMap.Get(formattedModelName); ok { + return cloneTierPricingConfig(config), true + } + } + return types.ModelTierPricingConfig{}, false +} + +func HasEnabledModelTierPricing(modelName string) bool { + config, ok := GetModelTierPricing(modelName) + return ok && config.Enabled && len(config.Tiers) > 0 +} + +func GetModelTierPricingBaseRatio(modelName string) (float64, bool) { + config, ok := GetModelTierPricing(modelName) + if !ok || !config.Enabled || len(config.Tiers) == 0 { + return 0, false + } + return config.Tiers[0].InputPrice / 2, true +} + +func GetModelTierPricingCopy() map[string]types.ModelTierPricingConfig { + raw := modelTierPricingMap.ReadAll() + cloned := make(map[string]types.ModelTierPricingConfig, len(raw)) + for modelName, config := range raw { + cloned[modelName] = cloneTierPricingConfig(config) + } + return cloned +} + +func resolveBaseCacheRatio(priceData types.PriceData) float64 { + if priceData.TierPricing != nil && priceData.TierPricing.BaseCacheRatio != nil { + return *priceData.TierPricing.BaseCacheRatio + } + return priceData.CacheRatio +} + +func ApplyModelTierPricing(modelName string, priceData types.PriceData, promptTokens int) (types.PriceData, bool) { + config, ok := GetModelTierPricing(modelName) + if !ok || !config.Enabled || config.Basis != TierPricingBasisPromptTokens { + return priceData, false + } + + baseCacheRatio := resolveBaseCacheRatio(priceData) + + for index, tier := range config.Tiers { + if promptTokens < tier.MinTokens { + continue + } + if tier.MaxTokens != nil && promptTokens >= *tier.MaxTokens { + continue + } + + nextPriceData := priceData + nextPriceData.UsePrice = false + nextPriceData.ModelPrice = 0 + nextPriceData.ModelRatio = tier.InputPrice / 2 + nextPriceData.CacheRatio = baseCacheRatio + if tier.InputPrice == 0 { + nextPriceData.CompletionRatio = 0 + if tier.CacheReadPrice != nil { + nextPriceData.CacheRatio = 0 + } + } else { + nextPriceData.CompletionRatio = tier.CompletionPrice / tier.InputPrice + if tier.CacheReadPrice != nil { + nextPriceData.CacheRatio = *tier.CacheReadPrice / tier.InputPrice + } + } + nextPriceData.TierPricing = &types.TierPricingMeta{ + Enabled: true, + Basis: config.Basis, + TierIndex: index, + MinTokens: tier.MinTokens, + MaxTokens: cloneMaxTokens(tier.MaxTokens), + BasisValue: promptTokens, + BaseCacheRatio: cloneFloat64(&baseCacheRatio), + } + return nextPriceData, true + } + + return priceData, false +} diff --git a/setting/ratio_setting/tier_pricing_test.go b/setting/ratio_setting/tier_pricing_test.go new file mode 100644 index 000000000000..eff7f0e8a0b5 --- /dev/null +++ b/setting/ratio_setting/tier_pricing_test.go @@ -0,0 +1,312 @@ +package ratio_setting + +import ( + "testing" + + "github.com/QuantumNous/new-api/types" + + "github.com/stretchr/testify/require" +) + +func intPtr(value int) *int { + return &value +} + +func resetModelTierPricingForTest(t *testing.T) { + t.Helper() + require.NoError(t, UpdateModelTierPricingByJSONString("{}")) + t.Cleanup(func() { + require.NoError(t, UpdateModelTierPricingByJSONString("{}")) + }) +} + +func TestUpdateModelTierPricingByJSONStringValidation(t *testing.T) { + resetModelTierPricingForTest(t) + + t.Run("empty json", func(t *testing.T) { + require.NoError(t, UpdateModelTierPricingByJSONString("")) + require.Empty(t, GetModelTierPricingCopy()) + }) + + t.Run("invalid json", func(t *testing.T) { + err := UpdateModelTierPricingByJSONString("{") + require.Error(t, err) + }) + + t.Run("first tier must start at zero", func(t *testing.T) { + err := UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 1, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`) + require.Error(t, err) + }) + + t.Run("tiers must be contiguous", func(t *testing.T) { + err := UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 100000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 100001, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`) + require.Error(t, err) + }) + + t.Run("final tier must omit max tokens", func(t *testing.T) { + err := UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "max_tokens": 300000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`) + require.Error(t, err) + }) + + t.Run("locked completion ratio models cannot enable tier pricing", func(t *testing.T) { + err := UpdateModelTierPricingByJSONString(`{ + "gpt-5": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "input_price": 2, + "completion_price": 16 + } + ] + } +}`) + require.Error(t, err) + }) +} + +func TestApplyModelTierPricingSelectsTierAndPreservesExtensions(t *testing.T) { + resetModelTierPricingForTest(t) + require.NoError(t, UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`)) + + basePriceData := types.PriceData{ + ModelRatio: 1, + CompletionRatio: 6, + CacheRatio: 0.1, + CacheCreationRatio: 1.25, + ImageRatio: 0.5, + AudioRatio: 0.75, + AudioCompletionRatio: 2, + } + + firstTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", basePriceData, 199999) + require.True(t, applied) + require.Equal(t, 1.0, firstTierPriceData.ModelRatio) + require.Equal(t, 6.0, firstTierPriceData.CompletionRatio) + require.Equal(t, 0.1, firstTierPriceData.CacheRatio) + require.Equal(t, 1.25, firstTierPriceData.CacheCreationRatio) + require.Equal(t, 0.5, firstTierPriceData.ImageRatio) + require.Equal(t, 0.75, firstTierPriceData.AudioRatio) + require.Equal(t, 2.0, firstTierPriceData.AudioCompletionRatio) + require.NotNil(t, firstTierPriceData.TierPricing) + require.Equal(t, 0, firstTierPriceData.TierPricing.TierIndex) + require.Equal(t, 199999, firstTierPriceData.TierPricing.BasisValue) + require.Equal(t, intPtr(200000), firstTierPriceData.TierPricing.MaxTokens) + + secondTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", basePriceData, 200000) + require.True(t, applied) + require.Equal(t, 2.0, secondTierPriceData.ModelRatio) + require.Equal(t, 4.5, secondTierPriceData.CompletionRatio) + require.Equal(t, 0.1, secondTierPriceData.CacheRatio) + require.Equal(t, 1.25, secondTierPriceData.CacheCreationRatio) + require.Equal(t, 0.5, secondTierPriceData.ImageRatio) + require.Equal(t, 0.75, secondTierPriceData.AudioRatio) + require.Equal(t, 2.0, secondTierPriceData.AudioCompletionRatio) + require.NotNil(t, secondTierPriceData.TierPricing) + require.Equal(t, 1, secondTierPriceData.TierPricing.TierIndex) + require.Nil(t, secondTierPriceData.TierPricing.MaxTokens) + require.Equal(t, 200000, secondTierPriceData.TierPricing.BasisValue) +} + +func TestApplyModelTierPricingAllowsOptionalCacheReadPrice(t *testing.T) { + resetModelTierPricingForTest(t) + require.NoError(t, UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18, + "cache_read_price": 0.4 + } + ] + } +}`)) + + config, ok := GetModelTierPricing("google/gemini-3.1-pro-preview") + require.True(t, ok) + require.Len(t, config.Tiers, 2) + require.Nil(t, config.Tiers[0].CacheReadPrice) + require.NotNil(t, config.Tiers[1].CacheReadPrice) + require.Equal(t, 0.4, *config.Tiers[1].CacheReadPrice) + + basePriceData := types.PriceData{ + ModelRatio: 1, + CompletionRatio: 6, + CacheRatio: 0.25, + } + + firstTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", basePriceData, 1000) + require.True(t, applied) + require.Equal(t, 0.25, firstTierPriceData.CacheRatio) + require.Equal(t, 6.0, firstTierPriceData.CompletionRatio) + + secondTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", basePriceData, 200000) + require.True(t, applied) + require.Equal(t, 0.1, secondTierPriceData.CacheRatio) + require.Equal(t, 4.5, secondTierPriceData.CompletionRatio) +} + +func TestApplyModelTierPricingReapplyRestoresBaseCacheRatioWhenTierOmitsCacheReadPrice(t *testing.T) { + resetModelTierPricingForTest(t) + require.NoError(t, UpdateModelTierPricingByJSONString(`{ + "google/gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12, + "cache_read_price": 0.2 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18 + } + ] + } +}`)) + + basePriceData := types.PriceData{ + ModelRatio: 1, + CompletionRatio: 6, + CacheRatio: 0.25, + } + + firstTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", basePriceData, 1000) + require.True(t, applied) + require.Equal(t, 0.1, firstTierPriceData.CacheRatio) + require.NotNil(t, firstTierPriceData.TierPricing) + require.NotNil(t, firstTierPriceData.TierPricing.BaseCacheRatio) + require.Equal(t, 0.25, *firstTierPriceData.TierPricing.BaseCacheRatio) + + secondTierPriceData, applied := ApplyModelTierPricing("google/gemini-3.1-pro-preview", firstTierPriceData, 200000) + require.True(t, applied) + require.Equal(t, 0.25, secondTierPriceData.CacheRatio) + require.Equal(t, 4.5, secondTierPriceData.CompletionRatio) + require.NotNil(t, secondTierPriceData.TierPricing) + require.NotNil(t, secondTierPriceData.TierPricing.BaseCacheRatio) + require.Equal(t, 0.25, *secondTierPriceData.TierPricing.BaseCacheRatio) +} + +func TestGetModelRatioOrPriceTreatsTierPricingAsConfigured(t *testing.T) { + resetModelTierPricingForTest(t) + require.NoError(t, UpdateModelTierPricingByJSONString(`{ + "tier-only-gemini-3.1-pro-preview": { + "enabled": true, + "basis": "prompt_tokens", + "tiers": [ + { + "min_tokens": 0, + "max_tokens": 200000, + "input_price": 2, + "completion_price": 12 + }, + { + "min_tokens": 200000, + "input_price": 4, + "completion_price": 18 + } + ] + } +}`)) + + ratio, usePrice, exist := GetModelRatioOrPrice("tier-only-gemini-3.1-pro-preview") + require.True(t, exist) + require.False(t, usePrice) + require.Equal(t, 1.0, ratio) + require.True(t, HasEnabledModelTierPricing("tier-only-gemini-3.1-pro-preview")) +} diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..f79b6ccdfb0a 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -25,6 +25,7 @@ type PriceData struct { Quota int // 按次计费的最终额度(MJ / Task) QuotaToPreConsume int // 按量计费的预消耗额度 GroupRatioInfo GroupRatioInfo + TierPricing *TierPricingMeta } func (p *PriceData) AddOtherRatio(key string, ratio float64) { @@ -38,5 +39,19 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) { } func (p *PriceData) ToSetting() string { - return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) + tierSetting := "nil" + if p.TierPricing != nil { + maxTokens := "nil" + if p.TierPricing.MaxTokens != nil { + maxTokens = fmt.Sprintf("%d", *p.TierPricing.MaxTokens) + } + tierSetting = fmt.Sprintf("{ Basis: %s, TierIndex: %d, MinTokens: %d, MaxTokens: %s, BasisValue: %d }", + p.TierPricing.Basis, + p.TierPricing.TierIndex, + p.TierPricing.MinTokens, + maxTokens, + p.TierPricing.BasisValue, + ) + } + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f, TierPricing: %s", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio, tierSetting) } diff --git a/types/tier_pricing.go b/types/tier_pricing.go new file mode 100644 index 000000000000..5b0fcefa06da --- /dev/null +++ b/types/tier_pricing.go @@ -0,0 +1,25 @@ +package types + +type ModelTierPricingTier struct { + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens,omitempty"` + InputPrice float64 `json:"input_price"` + CompletionPrice float64 `json:"completion_price"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` +} + +type ModelTierPricingConfig struct { + Enabled bool `json:"enabled"` + Basis string `json:"basis"` + Tiers []ModelTierPricingTier `json:"tiers"` +} + +type TierPricingMeta struct { + Enabled bool `json:"enabled"` + Basis string `json:"basis"` + TierIndex int `json:"tier_index"` + MinTokens int `json:"tier_min_tokens"` + MaxTokens *int `json:"tier_max_tokens,omitempty"` + BasisValue int `json:"tier_basis_value"` + BaseCacheRatio *float64 `json:"-"` +} diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx index 90858bf81376..92f9de79923e 100644 --- a/web/src/components/settings/RatioSetting.jsx +++ b/web/src/components/settings/RatioSetting.jsx @@ -35,6 +35,7 @@ const RatioSetting = () => { let [inputs, setInputs] = useState({ ModelPrice: '', ModelRatio: '', + ModelTierPricing: '', CacheRatio: '', CreateCacheRatio: '', CompletionRatio: '', diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index d5243afc6ed9..4cd223b98194 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -31,6 +31,8 @@ import { renderQuota, stringToColor, getLogOther, + getUsageLogModelPriceForRender, + describeUsageLogTierPricing, renderModelTag, renderModelPriceSimple, } from '../../../helpers'; @@ -143,10 +145,7 @@ function renderType(type, t) { function buildStreamStatusTooltip(ss, t) { if (!ss) return null; - const lines = [ - t('流状态') + ':' + t('异常'), - (ss.end_reason || 'unknown'), - ]; + const lines = [t('流状态') + ':' + t('异常'), ss.end_reason || 'unknown']; if (ss.error_count > 0) { lines.push(`${t('软错误')}: ${ss.error_count}`); } @@ -184,11 +183,7 @@ function renderIsStream(bool, t, streamStatus) { userSelect: 'none', }} > - + )} @@ -460,48 +455,59 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) { }; } + const renderedModelPrice = getUsageLogModelPriceForRender(other); + const tierPricingSummary = describeUsageLogTierPricing(other, t); + const summarySegments = other?.claude + ? renderModelPriceSimple( + other.model_ratio, + renderedModelPrice, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + other.cache_creation_tokens || 0, + other.cache_creation_ratio || 1.0, + other.cache_creation_tokens_5m || 0, + other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, + other.cache_creation_tokens_1h || 0, + other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, + false, + 1.0, + other?.is_system_prompt_overwritten, + 'claude', + billingDisplayMode, + 'segments', + ) + : renderModelPriceSimple( + other.model_ratio, + renderedModelPrice, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + 0, + 1.0, + 0, + 1.0, + 0, + 1.0, + false, + 1.0, + other?.is_system_prompt_overwritten, + 'openai', + billingDisplayMode, + 'segments', + ); + + if (tierPricingSummary && Array.isArray(summarySegments)) { + summarySegments.splice(1, 0, { + text: tierPricingSummary, + tone: 'secondary', + }); + } + return { - segments: other?.claude - ? renderModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - other.cache_creation_tokens || 0, - other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, - false, - 1.0, - other?.is_system_prompt_overwritten, - 'claude', - billingDisplayMode, - 'segments', - ) - : renderModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - 0, - 1.0, - 0, - 1.0, - 0, - 1.0, - false, - 1.0, - other?.is_system_prompt_overwritten, - 'openai', - billingDisplayMode, - 'segments', - ), + segments: summarySegments, }; } diff --git a/web/src/helpers/log.js b/web/src/helpers/log.js index 0f2190a65b0a..abcfded867b1 100644 --- a/web/src/helpers/log.js +++ b/web/src/helpers/log.js @@ -31,3 +31,91 @@ export function getLogOther(otherStr) { return null; } } + +function normalizeFiniteNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatTierTokenValue(value) { + const normalized = normalizeFiniteNumber(value); + if (normalized === null) { + return '-'; + } + if (normalized >= 1000000 && normalized % 1000000 === 0) { + return `${normalized / 1000000}M`; + } + if (normalized >= 1000 && normalized % 1000 === 0) { + return `${normalized / 1000}k`; + } + return `${normalized}`; +} + +export function getUsageLogBillingQuotaType(other) { + if (!other) { + return null; + } + const explicitQuotaType = normalizeFiniteNumber(other.billing_quota_type); + if (explicitQuotaType === 0 || explicitQuotaType === 1) { + return explicitQuotaType; + } + if (other?.tier_pricing_enabled === true) { + return 0; + } + return null; +} + +export function getUsageLogModelPriceForRender(other) { + const quotaType = getUsageLogBillingQuotaType(other); + if (quotaType === 0) { + return -1; + } + + const modelPrice = normalizeFiniteNumber(other?.model_price); + if (quotaType === 1) { + return modelPrice ?? 0; + } + + if (other?.tier_pricing_enabled === true) { + return -1; + } + + return modelPrice ?? -1; +} + +export function describeUsageLogTierPricing(other, t) { + if (!other?.tier_pricing_enabled) { + return null; + } + + const tierIndex = normalizeFiniteNumber(other?.tier_index); + const tierLabel = + tierIndex === null + ? t('阶梯定价') + : t('阶梯第 {{index}} 档', { index: tierIndex + 1 }); + + const minTokens = normalizeFiniteNumber(other?.tier_min_tokens); + const maxTokens = normalizeFiniteNumber(other?.tier_max_tokens); + const basisValue = normalizeFiniteNumber(other?.tier_basis_value); + + let rangeText = null; + if (minTokens !== null) { + rangeText = + maxTokens === null + ? `>= ${formatTierTokenValue(minTokens)}` + : `${formatTierTokenValue(minTokens)} <= x < ${formatTierTokenValue(maxTokens)}`; + } + + const parts = [tierLabel]; + if (rangeText) { + parts.push(t('区间 {{range}}', { range: rangeText })); + } + if (basisValue !== null) { + parts.push( + t('命中 {{value}} tokens', { + value: formatTierTokenValue(basisValue), + }), + ); + } + return parts.join(' · '); +} diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index e406b2ab8f7b..7b30448ed551 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -30,6 +30,8 @@ import { renderQuota, renderNumber, getLogOther, + getUsageLogModelPriceForRender, + describeUsageLogTierPricing, copy, renderClaudeLogContent, renderLogContent, @@ -163,7 +165,9 @@ export const useLogsData = () => { }; // Column visibility state - const [visibleColumns, setVisibleColumns] = useState(getInitialVisibleColumns); + const [visibleColumns, setVisibleColumns] = useState( + getInitialVisibleColumns, + ); const [showColumnSelector, setShowColumnSelector] = useState(false); const [billingDisplayMode, setBillingDisplayMode] = useState( getInitialBillingDisplayMode, @@ -380,9 +384,14 @@ export const useLogsData = () => { logs[i].timestamp2string = timestamp2string(logs[i].created_at); logs[i].key = logs[i].id; let other = getLogOther(logs[i].other); + const renderedModelPrice = getUsageLogModelPriceForRender(other); + const tierPricingSummary = describeUsageLogTierPricing(other, t); let expandDataLocal = []; - if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) { + if ( + isAdminUser && + (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6) + ) { expandDataLocal.push({ key: t('渠道信息'), value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, @@ -424,6 +433,12 @@ export const useLogsData = () => { value: other.cache_creation_tokens, }); } + if (tierPricingSummary) { + expandDataLocal.push({ + key: t('阶梯定价'), + value: tierPricingSummary, + }); + } if (logs[i].type === 2) { expandDataLocal.push({ key: t('日志详情'), @@ -431,7 +446,7 @@ export const useLogsData = () => { ? renderClaudeLogContent( other?.model_ratio, other.completion_ratio, - other.model_price, + renderedModelPrice, other.group_ratio, other?.user_group_ratio, other.cache_ratio || 1.0, @@ -449,7 +464,7 @@ export const useLogsData = () => { : renderLogContent( other?.model_ratio, other.completion_ratio, - other.model_price, + renderedModelPrice, other.group_ratio, other?.user_group_ratio, other.cache_ratio || 1.0, @@ -506,7 +521,7 @@ export const useLogsData = () => { other?.text_input, other?.text_output, other?.model_ratio, - other?.model_price, + renderedModelPrice, other?.completion_ratio, other?.audio_input, other?.audio_output, @@ -523,7 +538,7 @@ export const useLogsData = () => { logs[i].prompt_tokens, logs[i].completion_tokens, other.model_ratio, - other.model_price, + renderedModelPrice, other.completion_ratio, other.group_ratio, other?.user_group_ratio, @@ -546,7 +561,7 @@ export const useLogsData = () => { logs[i].prompt_tokens, logs[i].completion_tokens, other?.model_ratio, - other?.model_price, + renderedModelPrice, other?.completion_ratio, other?.group_ratio, other?.user_group_ratio, @@ -592,7 +607,14 @@ export const useLogsData = () => { expandDataLocal.push({ key: t('失败原因'), value: ( -
+
{other.reason}
), @@ -609,7 +631,8 @@ export const useLogsData = () => { const ss = other.stream_status; const isOk = ss.status === 'ok'; const statusLabel = isOk ? '✓ ' + t('正常') : '✗ ' + t('异常'); - let streamValue = statusLabel + ' (' + (ss.end_reason || 'unknown') + ')'; + let streamValue = + statusLabel + ' (' + (ss.end_reason || 'unknown') + ')'; if (ss.error_count > 0) { streamValue += ` [${t('软错误')}: ${ss.error_count}]`; } @@ -624,7 +647,14 @@ export const useLogsData = () => { expandDataLocal.push({ key: t('流错误详情'), value: ( -
+
{ss.errors.join('\n')}
), diff --git a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx index 5028a3ffdbaf..0802120f28d8 100644 --- a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx +++ b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx @@ -45,13 +45,62 @@ import { PAGE_SIZE, PRICE_SUFFIX, buildSummaryText, + breakpointsFromTiers, hasValue, useModelPricingEditorState, } from '../hooks/useModelPricingEditorState'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import { showError } from '../../../../helpers'; const { Text } = Typography; const EMPTY_CANDIDATE_MODEL_NAMES = []; +const EMPTY_TIER_DRAFT = { + inputPrice: '', + completionPrice: '', + cacheReadPrice: '', +}; +const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/; +const TOKEN_BOUNDARY_INPUT_REGEX = /^\d*$/; + +const hasTierReferenceInputPrice = (model) => + hasValue(model?.tierPricingTiers?.[0]?.inputPrice); + +const formatCompactTokenValue = (value) => { + if (!hasValue(value)) return null; + const num = Number(value); + if (!Number.isFinite(num)) return value; + if (num >= 1000 && num % 1000 === 0) { + return `${num / 1000}k`; + } + return String(num); +}; + +const buildTierRangeText = (tier) => { + const minLabel = formatCompactTokenValue(tier?.minTokens) || '0'; + const maxLabel = formatCompactTokenValue(tier?.maxTokens); + if (!maxLabel) { + return `>= ${minLabel}`; + } + return `${minLabel} <= x < ${maxLabel}`; +}; + +const buildInitialTierDraft = (model, index) => { + const targetTier = + index !== null && index !== undefined ? model?.tierPricingTiers?.[index] : null; + if (targetTier) { + return { + inputPrice: targetTier.inputPrice || '', + completionPrice: targetTier.completionPrice || '', + cacheReadPrice: targetTier.cacheReadPrice || '', + }; + } + return { + ...EMPTY_TIER_DRAFT, + inputPrice: model?.inputPrice || '', + completionPrice: model?.completionPrice || '', + cacheReadPrice: model?.cachePrice || '', + }; +}; const PriceInput = ({ label, @@ -84,6 +133,30 @@ const PriceInput = ({
); +const TierInputField = ({ + label, + hint = '', + value, + placeholder, + unitText = '', + onChange, +}) => ( +
+
+
{label}
+ {hint ?
{hint}
: null} +
+ + {unitText ? ( +
{unitText}
+ ) : null} +
+); + export default function ModelPricingEditor({ options, refresh, @@ -101,6 +174,11 @@ export default function ModelPricingEditor({ const [addVisible, setAddVisible] = useState(false); const [batchVisible, setBatchVisible] = useState(false); const [newModelName, setNewModelName] = useState(''); + const [tierEditorVisible, setTierEditorVisible] = useState(false); + const [editingTierIndex, setEditingTierIndex] = useState(null); + const [tierDraft, setTierDraft] = useState(EMPTY_TIER_DRAFT); + const [breakpointInputVisible, setBreakpointInputVisible] = useState(false); + const [breakpointInputValue, setBreakpointInputValue] = useState(''); const { selectedModel, @@ -118,9 +196,13 @@ export default function ModelPricingEditor({ filteredModels, pagedData, selectedWarnings, - previewRows, + previewSections, isOptionalFieldEnabled, handleOptionalFieldToggle, + handleTierPricingToggle, + handleAddBreakpoint, + handleRemoveBreakpoint, + handleSaveTierRow, handleNumericFieldChange, handleBillingModeChange, handleSubmit, @@ -167,6 +249,11 @@ export default function ModelPricingEditor({ {t('矛盾')} ) : null} + {record.tierPricingEnabled ? ( + + {t('阶梯定价')} + + ) : null} ), }, @@ -227,6 +314,69 @@ export default function ModelPricingEditor({ onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys), }; + const openTierEditor = (index) => { + if (!selectedModel) return; + setEditingTierIndex(index); + setTierDraft(buildInitialTierDraft(selectedModel, index)); + setTierEditorVisible(true); + }; + + const handleTierDraftChange = (field, value) => { + if (!NUMERIC_INPUT_REGEX.test(value)) { + return; + } + setTierDraft((previous) => ({ + ...previous, + [field]: value, + })); + }; + + const submitTierEditor = () => { + if (!selectedModel || editingTierIndex === null) return; + if (selectedModel.completionRatioLocked) { + showError(t('该模型补全倍率由后端锁定,不支持阶梯定价')); + return; + } + if (!hasValue(tierDraft.inputPrice)) { + showError(t('请填写输入价格')); + return; + } + if (!hasValue(tierDraft.completionPrice)) { + showError(t('请填写输出价格')); + return; + } + if ( + Number(tierDraft.inputPrice) === 0 && + ((hasValue(tierDraft.completionPrice) && + Number(tierDraft.completionPrice) !== 0) || + (hasValue(tierDraft.cacheReadPrice) && + Number(tierDraft.cacheReadPrice) !== 0)) + ) { + showError(t('输入价格为 0 时,输出和缓存读取价格也必须为 0')); + return; + } + handleSaveTierRow(editingTierIndex, tierDraft); + setTierEditorVisible(false); + setEditingTierIndex(null); + }; + + const submitBreakpoint = () => { + if (!breakpointInputValue) return; + const num = Number(breakpointInputValue); + if (!Number.isInteger(num) || num <= 0) { + showError(t('分界点必须是大于 0 的整数')); + return; + } + const existing = breakpointsFromTiers(selectedModel?.tierPricingTiers); + if (existing.includes(num)) { + showError(t('分界点已存在')); + return; + } + handleAddBreakpoint(num); + setBreakpointInputValue(''); + setBreakpointInputVisible(false); + }; + return ( <> @@ -424,94 +574,296 @@ export default function ModelPricingEditor({ background: 'var(--semi-color-fill-0)', }} > -
{t('基础价格')}
- handleNumericFieldChange('inputPrice', value)} - /> - {selectedModel.completionRatioLocked ? ( - +
+
{t('阶梯定价')}
+
+ {selectedModel.completionRatioLocked + ? t( + '该模型补全倍率由后端锁定,不支持阶梯定价。', + ) + : t( + '按输入 tokens 命中不同价格档位。开启后会自动把当前基础价格写入第 1 档,并改为写入 ModelTierPricing。', + )} +
+
+ - ) : null} - - handleNumericFieldChange('completionPrice', value) - } - headerAction={ - + + + + {selectedModel.tierPricingEnabled ? ( + <> +
+
{t('阶梯价格表')}
+
+ {t( + '通过添加分界点来划分价格区间,每个区间可单独设置价格。', + )} +
+
+
+ {t('分界点')}: + {breakpointsFromTiers(selectedModel.tierPricingTiers).map( + (bp, bpIndex) => ( + handleRemoveBreakpoint(bpIndex)} + > + {formatCompactTokenValue(bp)} + + ), )} - disabled={selectedModel.completionRatioLocked} - onChange={(checked) => - handleOptionalFieldToggle('completionPrice', checked) + {breakpointInputVisible ? ( + + { + if (TOKEN_BOUNDARY_INPUT_REGEX.test(value)) { + setBreakpointInputValue(value); + } + }} + onEnterPress={submitBreakpoint} + autoFocus + /> + + + + ) : ( + + )} +
+
+ {selectedModel.tierPricingTiers.map((tier, index) => ( + +
+
+
+ {t('第 {{index}} 档', { index: index + 1 })} +
+
+ {t('命中区间')} {buildTierRangeText(tier)} +
+
+ +
+
+
+
+ {t('输入价格')} +
+
+ {hasValue(tier.inputPrice) ? `$${tier.inputPrice}` : '-'} +
+
+
+
+ {t('输出价格')} +
+
+ {hasValue(tier.completionPrice) ? `$${tier.completionPrice}` : '-'} +
+
+
+
+ {t('缓存读取价格')} +
+
+ {hasValue(tier.cacheReadPrice) + ? `$${tier.cacheReadPrice}` + : t('未设置')} +
+
+
+
+ ))} +
+ + ) : ( + <> +
{t('基础价格')}
+ + handleNumericFieldChange('inputPrice', value) } /> - } - hidden={ - !isOptionalFieldEnabled(selectedModel, 'completionPrice') - } - disabled={ - !hasValue(selectedModel.inputPrice) || - selectedModel.completionRatioLocked - } - extraText={ - selectedModel.completionRatioLocked - ? t( - '后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。', + {selectedModel.completionRatioLocked ? ( + + ) : null} + + handleNumericFieldChange('completionPrice', value) + } + headerAction={ + - handleNumericFieldChange('cachePrice', value)} - headerAction={ - - handleOptionalFieldToggle('cachePrice', checked) + )} + disabled={selectedModel.completionRatioLocked} + onChange={(checked) => + handleOptionalFieldToggle('completionPrice', checked) + } + /> + } + hidden={ + !isOptionalFieldEnabled( + selectedModel, + 'completionPrice', + ) + } + disabled={ + !hasValue(selectedModel.inputPrice) || + selectedModel.completionRatioLocked + } + extraText={ + selectedModel.completionRatioLocked + ? t( + '后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。', + { + ratio: + selectedModel.lockedCompletionRatio || '-', + }, + ) + : !isOptionalFieldEnabled( + selectedModel, + 'completionPrice', + ) + ? t('当前未启用,需要时再打开即可。') + : '' } /> - } - hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')} - disabled={!hasValue(selectedModel.inputPrice)} - extraText={ - !isOptionalFieldEnabled(selectedModel, 'cachePrice') - ? t('当前未启用,需要时再打开即可。') - : '' - } - /> + + handleNumericFieldChange('cachePrice', value) + } + headerAction={ + + handleOptionalFieldToggle('cachePrice', checked) + } + /> + } + hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')} + disabled={!hasValue(selectedModel.inputPrice)} + extraText={ + !isOptionalFieldEnabled(selectedModel, 'cachePrice') + ? t('当前未启用,需要时再打开即可。') + : '' + } + /> + + )} +
+ + +
+
+ {selectedModel.tierPricingEnabled + ? t('非阶梯扩展价格') + : t('扩展价格')} +
+
+ {selectedModel.tierPricingEnabled + ? t( + '这些字段继续写入旧的全局倍率配置,不参与阶梯。它们会以第一档输入价格作为参考换算倍率。', + ) + : t('这些价格都是可选项,不填也可以。')} +
+
- - -
-
{t('扩展价格')}
-
- {t('这些价格都是可选项,不填也可以。')} -
-
} hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')} - disabled={!hasValue(selectedModel.inputPrice)} + disabled={ + selectedModel.tierPricingEnabled + ? !hasTierReferenceInputPrice(selectedModel) + : !hasValue(selectedModel.inputPrice) + } extraText={ !isOptionalFieldEnabled(selectedModel, 'imagePrice') ? t('当前未启用,需要时再打开即可。') + : selectedModel.tierPricingEnabled && + !hasTierReferenceInputPrice(selectedModel) + ? t('请先填写第一档输入价格。') : '' } /> @@ -601,13 +952,20 @@ export default function ModelPricingEditor({ /> } hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')} - disabled={!hasValue(selectedModel.inputPrice)} + disabled={ + selectedModel.tierPricingEnabled + ? !hasTierReferenceInputPrice(selectedModel) + : !hasValue(selectedModel.inputPrice) + } extraText={ !isOptionalFieldEnabled( selectedModel, 'audioInputPrice', ) ? t('当前未启用,需要时再打开即可。') + : selectedModel.tierPricingEnabled && + !hasTierReferenceInputPrice(selectedModel) + ? t('请先填写第一档输入价格。') : '' } /> @@ -666,20 +1024,45 @@ export default function ModelPricingEditor({ '下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。', )}
-
- {previewRows.map((row) => ( - - {row.label} - {row.value} - + + {previewSections.map((section) => ( + +
{section.title}
+ {section.code ? ( +
+                            {section.code}
+                          
+ ) : ( +
+ {section.rows.map((row) => ( + + {row.label} + {row.value} + + ))} +
+ )} +
))} -
+ )} @@ -705,6 +1088,65 @@ export default function ModelPricingEditor({ ) : null} + { + setTierEditorVisible(false); + setEditingTierIndex(null); + }} + onOk={submitTierEditor} + > + +
{t('价格')}
+
+ {t('输入与输出价格必填。缓存读取价格可留空,表示不单独配置。')} +
+
+ handleTierDraftChange('inputPrice', value)} + /> + + handleTierDraftChange('completionPrice', value) + } + /> + + handleTierDraftChange('cacheReadPrice', value) + } + /> +
+
+
+ { return Number.isFinite(num) ? num : null; }; +const isNonNegativeInteger = (value) => + Number.isInteger(value) && value >= 0; + const formatNumber = (value) => { const num = toNumberOrNull(value); if (num === null) { @@ -97,6 +104,127 @@ const normalizeCompletionRatioMeta = (rawMeta) => { }; }; +const normalizeTierPricingConfig = (rawConfig) => { + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + return { + enabled: false, + basis: TIER_BASIS_PROMPT_TOKENS, + tiers: [], + }; + } + + const tiers = Array.isArray(rawConfig.tiers) + ? rawConfig.tiers.map((tier) => ({ + minTokens: toNumericString(tier?.min_tokens), + maxTokens: hasValue(tier?.max_tokens) + ? toNumericString(tier.max_tokens) + : '', + inputPrice: toNumericString(tier?.input_price), + completionPrice: toNumericString(tier?.completion_price), + cacheReadPrice: toNumericString(tier?.cache_read_price), + })) + : []; + + return { + enabled: Boolean(rawConfig.enabled), + basis: + rawConfig.basis === TIER_BASIS_PROMPT_TOKENS + ? rawConfig.basis + : TIER_BASIS_PROMPT_TOKENS, + tiers, + }; +}; + +const getTierReferenceInputPrice = (model) => + toNumberOrNull(model?.tierPricingTiers?.[0]?.inputPrice); + +const getExtensionReferenceInputPrice = (model) => { + if (model?.tierPricingEnabled) { + return getTierReferenceInputPrice(model); + } + return toNumberOrNull(model?.inputPrice); +}; + +const buildDefaultTierRowFromModel = (model) => ({ + minTokens: '0', + maxTokens: '', + inputPrice: model?.inputPrice || '', + completionPrice: model?.completionPrice || '', + cacheReadPrice: model?.cachePrice || '', +}); + +const sortTierRows = (tiers) => + [...tiers].sort((left, right) => { + const leftMin = toNumberOrNull(left.minTokens); + const rightMin = toNumberOrNull(right.minTokens); + if (leftMin === null && rightMin === null) return 0; + if (leftMin === null) return 1; + if (rightMin === null) return -1; + return leftMin - rightMin; + }); + +export const breakpointsFromTiers = (tiers) => { + if (!Array.isArray(tiers) || tiers.length === 0) return []; + const sorted = sortTierRows(tiers); + return sorted + .filter((tier) => hasValue(tier.maxTokens)) + .map((tier) => Number(tier.maxTokens)); +}; + +const tiersFromBreakpoints = (breakpoints, existingTiers) => { + const sorted = [...breakpoints].sort((a, b) => a - b); + const boundaries = [0, ...sorted]; + const newTiers = []; + for (let i = 0; i < boundaries.length; i++) { + const min = boundaries[i]; + const max = i < sorted.length ? sorted[i] : null; + const existing = (existingTiers || []).find( + (t) => + toNumberOrNull(t.minTokens) === min && + (max === null + ? !hasValue(t.maxTokens) + : toNumberOrNull(t.maxTokens) === max), + ); + if (existing) { + newTiers.push({ ...existing }); + continue; + } + const overlapping = (existingTiers || []).find( + (t) => toNumberOrNull(t.minTokens) === min, + ); + if (overlapping) { + newTiers.push({ + ...overlapping, + minTokens: String(min), + maxTokens: max !== null ? String(max) : '', + }); + continue; + } + const prevTier = i > 0 ? newTiers[i - 1] : null; + newTiers.push({ + minTokens: String(min), + maxTokens: max !== null ? String(max) : '', + inputPrice: prevTier?.inputPrice || '', + completionPrice: prevTier?.completionPrice || '', + cacheReadPrice: prevTier?.cacheReadPrice || '', + }); + } + return newTiers; +}; + +const syncBasePricingFromFirstTier = (model) => { + const firstTier = model?.tierPricingTiers?.[0]; + if (!firstTier) { + return model; + } + return { + ...model, + inputPrice: firstTier.inputPrice ?? '', + completionPrice: firstTier.completionPrice ?? '', + cachePrice: firstTier.cacheReadPrice ?? '', + }; +}; + const buildModelState = (name, sourceMaps) => { const modelRatio = toNumericString(sourceMaps.ModelRatio[name]); const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]); @@ -111,11 +239,36 @@ const buildModelState = (name, sourceMaps) => { sourceMaps.AudioCompletionRatio[name], ); const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]); - const inputPrice = ratioToBasePrice(modelRatio); - const inputPriceNumber = toNumberOrNull(inputPrice); + const tierPricingConfig = normalizeTierPricingConfig( + sourceMaps.ModelTierPricing?.[name], + ); + const tierReferenceInputPrice = toNumberOrNull( + tierPricingConfig.tiers[0]?.inputPrice, + ); + const inputPrice = ratioToBasePrice(modelRatio) || tierPricingConfig.tiers[0]?.inputPrice || ''; + const completionPriceFromRatio = + inputPrice !== '' && + hasValue( + completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio, + ) + ? formatNumber( + Number(inputPrice) * + Number( + completionRatioMeta.locked + ? completionRatioMeta.ratio + : completionRatio, + ), + ) + : tierPricingConfig.tiers[0]?.completionPrice || ''; + const cachePriceFromRatio = + inputPrice !== '' && hasValue(cacheRatio) + ? formatNumber(Number(inputPrice) * Number(cacheRatio)) + : tierPricingConfig.tiers[0]?.cacheReadPrice || ''; + const extensionInputPriceNumber = + tierReferenceInputPrice !== null ? tierReferenceInputPrice : toNumberOrNull(inputPrice); const audioInputPrice = - inputPriceNumber !== null && hasValue(audioRatio) - ? formatNumber(inputPriceNumber * Number(audioRatio)) + extensionInputPriceNumber !== null && hasValue(audioRatio) + ? formatNumber(extensionInputPriceNumber * Number(audioRatio)) : ''; return { @@ -124,35 +277,20 @@ const buildModelState = (name, sourceMaps) => { billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token', fixedPrice, inputPrice, + tierPricingEnabled: tierPricingConfig.enabled, + tierPricingBasis: tierPricingConfig.basis, + tierPricingTiers: tierPricingConfig.tiers, completionRatioLocked: completionRatioMeta.locked, lockedCompletionRatio: completionRatioMeta.ratio, - completionPrice: - inputPriceNumber !== null && - hasValue( - completionRatioMeta.locked - ? completionRatioMeta.ratio - : completionRatio, - ) - ? formatNumber( - inputPriceNumber * - Number( - completionRatioMeta.locked - ? completionRatioMeta.ratio - : completionRatio, - ), - ) - : '', - cachePrice: - inputPriceNumber !== null && hasValue(cacheRatio) - ? formatNumber(inputPriceNumber * Number(cacheRatio)) - : '', + completionPrice: completionPriceFromRatio, + cachePrice: cachePriceFromRatio, createCachePrice: - inputPriceNumber !== null && hasValue(createCacheRatio) - ? formatNumber(inputPriceNumber * Number(createCacheRatio)) + extensionInputPriceNumber !== null && hasValue(createCacheRatio) + ? formatNumber(extensionInputPriceNumber * Number(createCacheRatio)) : '', imagePrice: - inputPriceNumber !== null && hasValue(imageRatio) - ? formatNumber(inputPriceNumber * Number(imageRatio)) + extensionInputPriceNumber !== null && hasValue(imageRatio) + ? formatNumber(extensionInputPriceNumber * Number(imageRatio)) : '', audioInputPrice, audioOutputPrice: @@ -182,8 +320,15 @@ const buildModelState = (name, sourceMaps) => { }; }; -export const isBasePricingUnset = (model) => - !hasValue(model.fixedPrice) && !hasValue(model.inputPrice); +export const isBasePricingUnset = (model) => { + if (model.billingMode === 'per-request') { + return !hasValue(model.fixedPrice); + } + if (model.tierPricingEnabled && model.tierPricingTiers.length > 0) { + return false; + } + return !hasValue(model.inputPrice); +}; export const getModelWarnings = (model, t) => { if (!model) { @@ -206,8 +351,28 @@ export const getModelWarnings = (model, t) => { ); } + if ( + model.tierPricingEnabled && + [ + model.rawRatios.modelRatio, + model.rawRatios.completionRatio, + model.rawRatios.cacheRatio, + ].some(hasValue) + ) { + warnings.push( + t('开启阶梯定价后,文本主价格将改为写入 ModelTierPricing,不再保留旧的平面基础倍率。'), + ); + } + + if (model.completionRatioLocked && model.tierPricingEnabled) { + warnings.push( + t('该模型补全倍率由后端锁定,不支持阶梯定价;请关闭阶梯定价后再保存。'), + ); + } + if ( !hasValue(model.inputPrice) && + !model.tierPricingEnabled && [ model.rawRatios.completionRatio, model.rawRatios.cacheRatio, @@ -226,6 +391,7 @@ export const getModelWarnings = (model, t) => { if ( model.billingMode === 'per-token' && + !model.tierPricingEnabled && hasDerivedPricing && !hasValue(model.inputPrice) ) { @@ -240,14 +406,65 @@ export const getModelWarnings = (model, t) => { warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。')); } + if ( + model.tierPricingEnabled && + [ + model.createCachePrice, + model.imagePrice, + model.audioInputPrice, + model.audioOutputPrice, + ].some(hasValue) + ) { + warnings.push( + t('非阶梯扩展价格会按第一档输入价格换算为全局倍率,命中更高阶梯时实际价格会随输入单价等比例变化。'), + ); + } + return warnings; }; +const formatCompactTokenCount = (value) => { + const num = toNumberOrNull(value); + if (num === null) return '-'; + if (num >= 1000 && num % 1000 === 0) { + return `${num / 1000}k`; + } + return String(num); +}; + +const buildTierRangeLabel = (tier) => { + const minLabel = formatCompactTokenCount(tier?.minTokens); + if (!hasValue(tier?.maxTokens)) { + return `>= ${minLabel}`; + } + return `${minLabel} <= x < ${formatCompactTokenCount(tier.maxTokens)}`; +}; + +const buildTierSummaryText = (model, t) => { + if (!model.tierPricingEnabled || model.tierPricingTiers.length === 0) { + return ''; + } + const ranges = model.tierPricingTiers.map(buildTierRangeLabel).join(' / '); + return `${t('阶梯定价')} ${model.tierPricingTiers.length}${t('档')} ${ranges}`; +}; + export const buildSummaryText = (model, t) => { if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) { return `${t('按次')} $${model.fixedPrice} / ${t('次')}`; } + if (model.tierPricingEnabled && model.tierPricingTiers.length > 0) { + const extraCount = [ + model.createCachePrice, + model.imagePrice, + model.audioInputPrice, + model.audioOutputPrice, + ].filter(hasValue).length; + const extraLabel = + extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : ''; + return `${buildTierSummaryText(model, t)}${extraLabel}`; + } + if (hasValue(model.inputPrice)) { const extraCount = [ model.completionPrice, @@ -267,16 +484,152 @@ export const buildSummaryText = (model, t) => { export const buildOptionalFieldToggles = (model) => ({ completionPrice: - model.completionRatioLocked || hasValue(model.completionPrice), - cachePrice: hasValue(model.cachePrice), + !model.tierPricingEnabled && + (model.completionRatioLocked || hasValue(model.completionPrice)), + cachePrice: !model.tierPricingEnabled && hasValue(model.cachePrice), createCachePrice: hasValue(model.createCachePrice), imagePrice: hasValue(model.imagePrice), audioInputPrice: hasValue(model.audioInputPrice), audioOutputPrice: hasValue(model.audioOutputPrice), }); +const validateAndSerializeTierPricing = (model, t) => { + if (model.billingMode !== 'per-token' || !model.tierPricingEnabled) { + return null; + } + + if (model.completionRatioLocked) { + throw new Error( + t('模型 {{name}} 的补全倍率由后端锁定,不支持阶梯定价', { + name: model.name, + }), + ); + } + + const tiers = model.tierPricingTiers.map((tier, index) => { + const minTokens = toNumberOrNull(tier.minTokens); + const maxTokens = hasValue(tier.maxTokens) + ? toNumberOrNull(tier.maxTokens) + : null; + const inputPrice = toNumberOrNull(tier.inputPrice); + const completionPrice = toNumberOrNull(tier.completionPrice); + const cacheReadPrice = toNumberOrNull(tier.cacheReadPrice); + + if (minTokens === null || inputPrice === null || completionPrice === null) { + throw new Error( + t('模型 {{name}} 的第 {{index}} 档缺少必填字段', { + name: model.name, + index: index + 1, + }), + ); + } + + return { + min_tokens: minTokens, + max_tokens: maxTokens, + input_price: inputPrice, + completion_price: completionPrice, + ...(cacheReadPrice !== null + ? { cache_read_price: cacheReadPrice } + : {}), + }; + }); + + if (model.tierPricingEnabled && tiers.length === 0) { + throw new Error( + t('模型 {{name}} 已开启阶梯定价,但还没有配置任何阶梯', { + name: model.name, + }), + ); + } + + tiers.sort((a, b) => a.min_tokens - b.min_tokens); + + tiers.forEach((tier, index) => { + if ( + !isNonNegativeInteger(tier.min_tokens) || + (tier.max_tokens !== null && !isNonNegativeInteger(tier.max_tokens)) + ) { + throw new Error( + t('模型 {{name}} 的阶梯 tokens 必须是非负整数', { + name: model.name, + }), + ); + } + if (tier.min_tokens < 0) { + throw new Error( + t('模型 {{name}} 的第 {{index}} 档最小输入 tokens 不能小于 0', { + name: model.name, + index: index + 1, + }), + ); + } + if (index === 0 && tier.min_tokens !== 0) { + throw new Error( + t('模型 {{name}} 的第一档必须从 0 开始', { name: model.name }), + ); + } + if (tier.input_price < 0 || tier.completion_price < 0) { + throw new Error( + t('模型 {{name}} 的阶梯价格不能为负数', { name: model.name }), + ); + } + if (hasValue(tier.cache_read_price) && tier.cache_read_price < 0) { + throw new Error( + t('模型 {{name}} 的缓存读取价格不能为负数', { name: model.name }), + ); + } + if ( + tier.input_price === 0 && + (tier.completion_price !== 0 || + (hasValue(tier.cache_read_price) && tier.cache_read_price !== 0)) + ) { + throw new Error( + t('模型 {{name}} 的某个阶梯输入价格为 0 时,输出和缓存读取价格也必须为 0', { + name: model.name, + }), + ); + } + if (tier.max_tokens !== null && tier.max_tokens <= tier.min_tokens) { + throw new Error( + t('模型 {{name}} 的第 {{index}} 档最大输入 tokens 必须大于最小值', { + name: model.name, + index: index + 1, + }), + ); + } + if (index < tiers.length - 1) { + if (tier.max_tokens === null) { + throw new Error( + t('模型 {{name}} 只有最后一档可以不填写最大值', { + name: model.name, + }), + ); + } + if (tiers[index + 1].min_tokens !== tier.max_tokens) { + throw new Error( + t('模型 {{name}} 的阶梯必须连续且不能重叠', { name: model.name }), + ); + } + } else if (tier.max_tokens !== null) { + throw new Error( + t('模型 {{name}} 的最后一档最大值必须留空,表示无上限', { + name: model.name, + }), + ); + } + }); + + return { + enabled: model.tierPricingEnabled, + basis: TIER_BASIS_PROMPT_TOKENS, + tiers, + }; +}; + const serializeModel = (model, t) => { const result = { + ModelTierPricing: null, ModelPrice: null, ModelRatio: null, CompletionRatio: null, @@ -294,28 +647,35 @@ const serializeModel = (model, t) => { return result; } - const inputPrice = toNumberOrNull(model.inputPrice); - const completionPrice = toNumberOrNull(model.completionPrice); - const cachePrice = toNumberOrNull(model.cachePrice); + result.ModelTierPricing = validateAndSerializeTierPricing(model, t); + + const inputPrice = model.tierPricingEnabled + ? getTierReferenceInputPrice(model) + : toNumberOrNull(model.inputPrice); + const completionPrice = model.tierPricingEnabled + ? null + : toNumberOrNull(model.completionPrice); + const cachePrice = model.tierPricingEnabled + ? null + : toNumberOrNull(model.cachePrice); const createCachePrice = toNumberOrNull(model.createCachePrice); const imagePrice = toNumberOrNull(model.imagePrice); const audioInputPrice = toNumberOrNull(model.audioInputPrice); const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); const hasDependentPrice = [ - completionPrice, - cachePrice, createCachePrice, imagePrice, audioInputPrice, audioOutputPrice, + ...(model.tierPricingEnabled ? [] : [completionPrice, cachePrice]), ].some((value) => value !== null); if (inputPrice === null) { if (hasDependentPrice) { throw new Error( t( - '模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', + '模型 {{name}} 缺少参考输入价格,无法计算扩展价格对应的倍率', { name: model.name, }, @@ -323,15 +683,15 @@ const serializeModel = (model, t) => { ); } - if (hasValue(model.rawRatios.modelRatio)) { + if (!model.tierPricingEnabled && hasValue(model.rawRatios.modelRatio)) { result.ModelRatio = toNormalizedNumber(model.rawRatios.modelRatio); } - if (hasValue(model.rawRatios.completionRatio)) { + if (!model.tierPricingEnabled && hasValue(model.rawRatios.completionRatio)) { result.CompletionRatio = toNormalizedNumber( model.rawRatios.completionRatio, ); } - if (hasValue(model.rawRatios.cacheRatio)) { + if (!model.tierPricingEnabled && hasValue(model.rawRatios.cacheRatio)) { result.CacheRatio = toNormalizedNumber(model.rawRatios.cacheRatio); } if (hasValue(model.rawRatios.createCacheRatio)) { @@ -353,28 +713,30 @@ const serializeModel = (model, t) => { return result; } - result.ModelRatio = toNormalizedNumber(inputPrice / 2); + if (!model.tierPricingEnabled) { + result.ModelRatio = toNormalizedNumber(inputPrice / 2); - if (!model.completionRatioLocked && completionPrice !== null) { - result.CompletionRatio = toNormalizedNumber(completionPrice / inputPrice); - } else if ( - model.completionRatioLocked && - hasValue(model.rawRatios.completionRatio) - ) { - result.CompletionRatio = toNormalizedNumber( - model.rawRatios.completionRatio, - ); - } - if (cachePrice !== null) { - result.CacheRatio = toNormalizedNumber(cachePrice / inputPrice); + if (!model.completionRatioLocked && completionPrice !== null) { + result.CompletionRatio = toNormalizedNumber(completionPrice / inputPrice); + } else if ( + model.completionRatioLocked && + hasValue(model.rawRatios.completionRatio) + ) { + result.CompletionRatio = toNormalizedNumber( + model.rawRatios.completionRatio, + ); + } + if (cachePrice !== null) { + result.CacheRatio = toNormalizedNumber(cachePrice / inputPrice); + } } - if (createCachePrice !== null) { + if (createCachePrice !== null && inputPrice !== 0) { result.CreateCacheRatio = toNormalizedNumber(createCachePrice / inputPrice); } - if (imagePrice !== null) { + if (imagePrice !== null && inputPrice !== 0) { result.ImageRatio = toNormalizedNumber(imagePrice / inputPrice); } - if (audioInputPrice !== null) { + if (audioInputPrice !== null && inputPrice !== 0) { result.AudioRatio = toNormalizedNumber(audioInputPrice / inputPrice); } if (audioOutputPrice !== null) { @@ -393,70 +755,163 @@ const serializeModel = (model, t) => { return result; }; -export const buildPreviewRows = (model, t) => { +export const buildPreviewSections = (model, t) => { if (!model) return []; if (model.billingMode === 'per-request') { return [ { - key: 'ModelPrice', - label: 'ModelPrice', - value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'), + key: 'legacy-flat-fields', + title: t('Legacy Flat Fields'), + rows: [ + { + key: 'ModelPrice', + label: 'ModelPrice', + value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'), + }, + ], }, ]; } - const inputPrice = toNumberOrNull(model.inputPrice); - if (inputPrice === null) { - return [ - { - key: 'ModelRatio', - label: 'ModelRatio', - value: hasValue(model.rawRatios.modelRatio) - ? model.rawRatios.modelRatio - : t('空'), - }, - { - key: 'CompletionRatio', - label: 'CompletionRatio', - value: hasValue(model.rawRatios.completionRatio) - ? model.rawRatios.completionRatio - : t('空'), - }, - { - key: 'CacheRatio', - label: 'CacheRatio', - value: hasValue(model.rawRatios.cacheRatio) - ? model.rawRatios.cacheRatio - : t('空'), - }, + if (model.tierPricingEnabled) { + const referenceInputPrice = getTierReferenceInputPrice(model); + const tierPreviewValue = JSON.stringify( { - key: 'CreateCacheRatio', - label: 'CreateCacheRatio', - value: hasValue(model.rawRatios.createCacheRatio) - ? model.rawRatios.createCacheRatio - : t('空'), + [model.name]: { + enabled: model.tierPricingEnabled, + basis: TIER_BASIS_PROMPT_TOKENS, + tiers: model.tierPricingTiers.map((tier) => ({ + min_tokens: hasValue(tier.minTokens) ? Number(tier.minTokens) : null, + max_tokens: hasValue(tier.maxTokens) ? Number(tier.maxTokens) : null, + input_price: hasValue(tier.inputPrice) ? Number(tier.inputPrice) : null, + completion_price: hasValue(tier.completionPrice) + ? Number(tier.completionPrice) + : null, + ...(hasValue(tier.cacheReadPrice) + ? { cache_read_price: Number(tier.cacheReadPrice) } + : {}), + })), + }, }, + null, + 2, + ); + + return [ { - key: 'ImageRatio', - label: 'ImageRatio', - value: hasValue(model.rawRatios.imageRatio) - ? model.rawRatios.imageRatio - : t('空'), + key: 'model-tier-pricing', + title: 'ModelTierPricing', + code: tierPreviewValue, }, { - key: 'AudioRatio', - label: 'AudioRatio', - value: hasValue(model.rawRatios.audioRatio) - ? model.rawRatios.audioRatio - : t('空'), + key: 'legacy-flat-fields', + title: t('Legacy Flat Fields'), + rows: [ + { + key: 'CreateCacheRatio', + label: 'CreateCacheRatio', + value: + referenceInputPrice !== null && + referenceInputPrice !== 0 && + hasValue(model.createCachePrice) + ? formatNumber(Number(model.createCachePrice) / referenceInputPrice) + : t('空'), + }, + { + key: 'ImageRatio', + label: 'ImageRatio', + value: + referenceInputPrice !== null && + referenceInputPrice !== 0 && + hasValue(model.imagePrice) + ? formatNumber(Number(model.imagePrice) / referenceInputPrice) + : t('空'), + }, + { + key: 'AudioRatio', + label: 'AudioRatio', + value: + referenceInputPrice !== null && + referenceInputPrice !== 0 && + hasValue(model.audioInputPrice) + ? formatNumber(Number(model.audioInputPrice) / referenceInputPrice) + : t('空'), + }, + { + key: 'AudioCompletionRatio', + label: 'AudioCompletionRatio', + value: + hasValue(model.audioOutputPrice) && + hasValue(model.audioInputPrice) && + Number(model.audioInputPrice) !== 0 + ? formatNumber( + Number(model.audioOutputPrice) / Number(model.audioInputPrice), + ) + : t('空'), + }, + ], }, + ]; + } + + const inputPrice = toNumberOrNull(model.inputPrice); + if (inputPrice === null) { + return [ { - key: 'AudioCompletionRatio', - label: 'AudioCompletionRatio', - value: hasValue(model.rawRatios.audioCompletionRatio) - ? model.rawRatios.audioCompletionRatio - : t('空'), + key: 'legacy-flat-fields', + title: t('Legacy Flat Fields'), + rows: [ + { + key: 'ModelRatio', + label: 'ModelRatio', + value: hasValue(model.rawRatios.modelRatio) + ? model.rawRatios.modelRatio + : t('空'), + }, + { + key: 'CompletionRatio', + label: 'CompletionRatio', + value: hasValue(model.rawRatios.completionRatio) + ? model.rawRatios.completionRatio + : t('空'), + }, + { + key: 'CacheRatio', + label: 'CacheRatio', + value: hasValue(model.rawRatios.cacheRatio) + ? model.rawRatios.cacheRatio + : t('空'), + }, + { + key: 'CreateCacheRatio', + label: 'CreateCacheRatio', + value: hasValue(model.rawRatios.createCacheRatio) + ? model.rawRatios.createCacheRatio + : t('空'), + }, + { + key: 'ImageRatio', + label: 'ImageRatio', + value: hasValue(model.rawRatios.imageRatio) + ? model.rawRatios.imageRatio + : t('空'), + }, + { + key: 'AudioRatio', + label: 'AudioRatio', + value: hasValue(model.rawRatios.audioRatio) + ? model.rawRatios.audioRatio + : t('空'), + }, + { + key: 'AudioCompletionRatio', + label: 'AudioCompletionRatio', + value: hasValue(model.rawRatios.audioCompletionRatio) + ? model.rawRatios.audioCompletionRatio + : t('空'), + }, + ], }, ]; } @@ -470,56 +925,62 @@ export const buildPreviewRows = (model, t) => { return [ { - key: 'ModelRatio', - label: 'ModelRatio', - value: formatNumber(inputPrice / 2), - }, - { - key: 'CompletionRatio', - label: 'CompletionRatio', - value: model.completionRatioLocked - ? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})` - : completionPrice !== null - ? formatNumber(completionPrice / inputPrice) - : t('空'), - }, - { - key: 'CacheRatio', - label: 'CacheRatio', - value: - cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'), - }, - { - key: 'CreateCacheRatio', - label: 'CreateCacheRatio', - value: - createCachePrice !== null - ? formatNumber(createCachePrice / inputPrice) - : t('空'), - }, - { - key: 'ImageRatio', - label: 'ImageRatio', - value: - imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'), - }, - { - key: 'AudioRatio', - label: 'AudioRatio', - value: - audioInputPrice !== null - ? formatNumber(audioInputPrice / inputPrice) - : t('空'), - }, - { - key: 'AudioCompletionRatio', - label: 'AudioCompletionRatio', - value: - audioOutputPrice !== null && - audioInputPrice !== null && - audioInputPrice !== 0 - ? formatNumber(audioOutputPrice / audioInputPrice) - : t('空'), + key: 'legacy-flat-fields', + title: t('Legacy Flat Fields'), + rows: [ + { + key: 'ModelRatio', + label: 'ModelRatio', + value: formatNumber(inputPrice / 2), + }, + { + key: 'CompletionRatio', + label: 'CompletionRatio', + value: model.completionRatioLocked + ? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})` + : completionPrice !== null + ? formatNumber(completionPrice / inputPrice) + : t('空'), + }, + { + key: 'CacheRatio', + label: 'CacheRatio', + value: + cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'), + }, + { + key: 'CreateCacheRatio', + label: 'CreateCacheRatio', + value: + createCachePrice !== null + ? formatNumber(createCachePrice / inputPrice) + : t('空'), + }, + { + key: 'ImageRatio', + label: 'ImageRatio', + value: + imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'), + }, + { + key: 'AudioRatio', + label: 'AudioRatio', + value: + audioInputPrice !== null + ? formatNumber(audioInputPrice / inputPrice) + : t('空'), + }, + { + key: 'AudioCompletionRatio', + label: 'AudioCompletionRatio', + value: + audioOutputPrice !== null && + audioInputPrice !== null && + audioInputPrice !== 0 + ? formatNumber(audioOutputPrice / audioInputPrice) + : t('空'), + }, + ], }, ]; }; @@ -545,6 +1006,7 @@ export function useModelPricingEditorState({ const sourceMaps = { ModelPrice: parseOptionJSON(options.ModelPrice), ModelRatio: parseOptionJSON(options.ModelRatio), + ModelTierPricing: parseOptionJSON(options.ModelTierPricing), CompletionRatio: parseOptionJSON(options.CompletionRatio), CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta), CacheRatio: parseOptionJSON(options.CacheRatio), @@ -558,6 +1020,7 @@ export function useModelPricingEditorState({ ...candidateModelNames, ...Object.keys(sourceMaps.ModelPrice), ...Object.keys(sourceMaps.ModelRatio), + ...Object.keys(sourceMaps.ModelTierPricing), ...Object.keys(sourceMaps.CompletionRatio), ...Object.keys(sourceMaps.CompletionRatioMeta), ...Object.keys(sourceMaps.CacheRatio), @@ -630,8 +1093,8 @@ export function useModelPricingEditorState({ [selectedModel, t], ); - const previewRows = useMemo( - () => buildPreviewRows(selectedModel, t), + const previewSections = useMemo( + () => buildPreviewSections(selectedModel, t), [selectedModel, t], ); @@ -713,6 +1176,114 @@ export function useModelPricingEditorState({ }); }; + const handleTierPricingToggle = (checked) => { + if (!selectedModel) return; + if (checked && selectedModel.completionRatioLocked) { + showError(t('该模型补全倍率由后端锁定,不支持阶梯定价')); + return; + } + upsertModel(selectedModel.name, (model) => { + const nextModel = { + ...model, + tierPricingEnabled: checked, + tierPricingBasis: TIER_BASIS_PROMPT_TOKENS, + }; + if (checked && nextModel.tierPricingTiers.length === 0) { + nextModel.tierPricingTiers = [buildDefaultTierRowFromModel(model)]; + } + if (!checked) { + return syncBasePricingFromFirstTier(nextModel); + } + return nextModel; + }); + }; + + const handleTierFieldChange = (index, field, value) => { + if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) { + return; + } + + upsertModel(selectedModel.name, (model) => + syncBasePricingFromFirstTier({ + ...model, + tierPricingTiers: model.tierPricingTiers.map((tier, tierIndex) => + tierIndex === index ? { ...tier, [field]: value } : tier, + ), + }), + ); + }; + + const handleAddBreakpoint = (value) => { + if (!selectedModel) return; + const num = Number(value); + if (!Number.isInteger(num) || num <= 0) return; + upsertModel(selectedModel.name, (model) => { + const existing = breakpointsFromTiers(model.tierPricingTiers); + if (existing.includes(num)) return model; + const nextBreakpoints = [...existing, num]; + return syncBasePricingFromFirstTier({ + ...model, + tierPricingTiers: tiersFromBreakpoints( + nextBreakpoints, + model.tierPricingTiers, + ), + }); + }); + }; + + const handleRemoveBreakpoint = (bpIndex) => { + if (!selectedModel) return; + upsertModel(selectedModel.name, (model) => { + const existing = breakpointsFromTiers(model.tierPricingTiers); + const nextBreakpoints = existing.filter((_, i) => i !== bpIndex); + return syncBasePricingFromFirstTier({ + ...model, + tierPricingTiers: tiersFromBreakpoints( + nextBreakpoints, + model.tierPricingTiers, + ), + }); + }); + }; + + const handleEditBreakpoint = (bpIndex, newValue) => { + if (!selectedModel) return; + const num = Number(newValue); + if (!Number.isInteger(num) || num <= 0) return; + upsertModel(selectedModel.name, (model) => { + const existing = breakpointsFromTiers(model.tierPricingTiers); + if (existing.some((v, i) => i !== bpIndex && v === num)) return model; + const nextBreakpoints = existing.map((v, i) => (i === bpIndex ? num : v)); + return syncBasePricingFromFirstTier({ + ...model, + tierPricingTiers: tiersFromBreakpoints( + nextBreakpoints, + model.tierPricingTiers, + ), + }); + }); + }; + + const handleSaveTierRow = (index, priceData) => { + if (!selectedModel || index === null || index === undefined) return; + upsertModel(selectedModel.name, (model) => { + const nextTiers = model.tierPricingTiers.map((tier, tierIndex) => + tierIndex === index + ? { + ...tier, + inputPrice: priceData.inputPrice, + completionPrice: priceData.completionPrice, + cacheReadPrice: priceData.cacheReadPrice, + } + : tier, + ); + return syncBasePricingFromFirstTier({ + ...model, + tierPricingTiers: nextTiers, + }); + }); + }; + const fillDerivedPricesFromBase = (model, nextInputPrice) => { const baseNumber = toNumberOrNull(nextInputPrice); if (baseNumber === null) { @@ -779,6 +1350,7 @@ export function useModelPricingEditorState({ upsertModel(selectedModel.name, (model) => ({ ...model, billingMode: value, + tierPricingEnabled: value === 'per-request' ? false : model.tierPricingEnabled, })); }; @@ -835,6 +1407,19 @@ export function useModelPricingEditorState({ return false; } + if (selectedModel.tierPricingEnabled) { + const lockedTargets = selectedModelNames.filter((modelName) => { + const targetModel = models.find((item) => item.name === modelName); + return targetModel?.completionRatioLocked; + }); + if (lockedTargets.length > 0) { + showError( + t('已勾选模型中包含补全倍率锁定模型,不能批量应用阶梯定价'), + ); + return false; + } + } + const sourceToggles = optionalFieldToggles[selectedModel.name] || {}; setModels((previous) => @@ -854,6 +1439,11 @@ export function useModelPricingEditorState({ imagePrice: selectedModel.imagePrice, audioInputPrice: selectedModel.audioInputPrice, audioOutputPrice: selectedModel.audioOutputPrice, + tierPricingEnabled: selectedModel.tierPricingEnabled, + tierPricingBasis: selectedModel.tierPricingBasis, + tierPricingTiers: selectedModel.tierPricingTiers.map((tier) => ({ + ...tier, + })), }; if ( @@ -877,10 +1467,15 @@ export function useModelPricingEditorState({ selectedModelNames.forEach((modelName) => { const targetModel = models.find((item) => item.name === modelName); next[modelName] = { - completionPrice: targetModel?.completionRatioLocked - ? true - : Boolean(sourceToggles.completionPrice), - cachePrice: Boolean(sourceToggles.cachePrice), + completionPrice: + selectedModel.tierPricingEnabled + ? false + : targetModel?.completionRatioLocked + ? true + : Boolean(sourceToggles.completionPrice), + cachePrice: selectedModel.tierPricingEnabled + ? false + : Boolean(sourceToggles.cachePrice), createCachePrice: Boolean(sourceToggles.createCachePrice), imagePrice: Boolean(sourceToggles.imagePrice), audioInputPrice: Boolean(sourceToggles.audioInputPrice), @@ -905,6 +1500,7 @@ export function useModelPricingEditorState({ setLoading(true); try { const output = { + ModelTierPricing: {}, ModelPrice: {}, ModelRatio: {}, CompletionRatio: {}, @@ -924,15 +1520,23 @@ export function useModelPricingEditorState({ }); } - const requestQueue = Object.entries(output).map(([key, value]) => - API.put('/api/option/', { + const orderedKeys = [ + 'ModelTierPricing', + 'ModelPrice', + 'ModelRatio', + 'CompletionRatio', + 'CacheRatio', + 'CreateCacheRatio', + 'ImageRatio', + 'AudioRatio', + 'AudioCompletionRatio', + ]; + + for (const key of orderedKeys) { + const res = await API.put('/api/option/', { key, - value: JSON.stringify(value, null, 2), - }), - ); - - const results = await Promise.all(requestQueue); - for (const res of results) { + value: JSON.stringify(output[key], null, 2), + }); if (!res?.data?.success) { throw new Error(res?.data?.message || t('保存失败,请重试')); } @@ -965,9 +1569,15 @@ export function useModelPricingEditorState({ filteredModels, pagedData, selectedWarnings, - previewRows, + previewSections, isOptionalFieldEnabled, handleOptionalFieldToggle, + handleTierPricingToggle, + handleTierFieldChange, + handleAddBreakpoint, + handleRemoveBreakpoint, + handleEditBreakpoint, + handleSaveTierRow, handleNumericFieldChange, handleBillingModeChange, handleSubmit,