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',
}}
>
-
+ {section.code}
+
+ ) : (
+