Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ func InitOptionMap() {
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TieredPricing"] = ratio_setting.TieredPricing2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
Expand Down Expand Up @@ -436,6 +437,8 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "TieredPricing":
err = ratio_setting.UpdateTieredPricingByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
Expand Down
31 changes: 18 additions & 13 deletions model/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ import (
)

type Pricing struct {
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
PricingVersion string `json:"pricing_version,omitempty"`
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
PricingVersion string `json:"pricing_version,omitempty"`
TieredPricing []ratio_setting.TieredPricingTier `json:"tiered_pricing,omitempty"`
}

type PricingVendor struct {
Expand Down Expand Up @@ -296,6 +297,10 @@ func updatePricing() {
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
// Add tiered pricing if available
if tiers := ratio_setting.GetTieredPricing(model); tiers != nil {
pricing.TieredPricing = tiers
}
}
pricingMap = append(pricingMap, pricing)
}
Expand Down
50 changes: 34 additions & 16 deletions relay/helper/price.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var audioRatio float64
var audioCompletionRatio float64
var freeModel bool
var hasTieredPricing bool
var tieredModelRatio, tieredCompletionRatio, tieredCacheRatio float64
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
if meta.MaxTokens != 0 {
Expand All @@ -87,8 +89,20 @@ 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
preConsumedQuota = int(float64(preConsumedTokens) * ratio)

// Check tiered pricing: use highest tier for conservative pre-consumption
if highestTier := ratio_setting.GetHighestTier(info.OriginModelName); highestTier != nil {
hasTieredPricing = true
tieredModelRatio = highestTier.ModelRatio
tieredCompletionRatio = highestTier.CompletionRatio
tieredCacheRatio = highestTier.CacheRatio
// Use highest tier's model ratio for pre-consumption to be conservative
ratio := highestTier.ModelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
}
Comment on lines +93 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't base free-model detection on the non-tiered ratio.

This block can pre-consume using highestTier.ModelRatio, but Line 125 still decides "free model" from the base modelRatio. If a model is free below the threshold and paid above it, paid tiered requests will have their pre-consumption zeroed back out.

[suggested fix: track the effective ratio used for pre-consumption and reuse that in the later free-model check.]

💡 Proposed fix
 	var freeModel bool
 	var hasTieredPricing bool
 	var tieredModelRatio, tieredCompletionRatio, tieredCacheRatio float64
+	var effectivePreconsumeModelRatio float64
 	if !usePrice {
 		preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
 		if meta.MaxTokens != 0 {
 			preConsumedTokens += meta.MaxTokens
@@
 		modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
+		effectivePreconsumeModelRatio = modelRatio
 		if !success {
@@
 		// Check tiered pricing: use highest tier for conservative pre-consumption
 		if highestTier := ratio_setting.GetHighestTier(info.OriginModelName); highestTier != nil {
 			hasTieredPricing = true
 			tieredModelRatio = highestTier.ModelRatio
 			tieredCompletionRatio = highestTier.CompletionRatio
 			tieredCacheRatio = highestTier.CacheRatio
+			effectivePreconsumeModelRatio = highestTier.ModelRatio
 			// Use highest tier's model ratio for pre-consumption to be conservative
-			ratio := highestTier.ModelRatio * groupRatioInfo.GroupRatio
+			ratio := effectivePreconsumeModelRatio * groupRatioInfo.GroupRatio
 			preConsumedQuota = int(float64(preConsumedTokens) * ratio)
 		} else {
-			ratio := modelRatio * groupRatioInfo.GroupRatio
+			ratio := effectivePreconsumeModelRatio * groupRatioInfo.GroupRatio
 			preConsumedQuota = int(float64(preConsumedTokens) * ratio)
 		}
 	} else {
@@
 		} else {
-			if modelRatio == 0 {
+			if effectivePreconsumeModelRatio == 0 {
 				preConsumedQuota = 0
 				freeModel = true
 			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/helper/price.go` around lines 93 - 105, The code uses
highestTier.ModelRatio to compute preConsumedQuota but later still checks "free
model" using the base modelRatio, causing tiered paid requests to be
misclassified; fix by introducing an effectiveRatio (e.g., effectiveRatio := if
highestTier != nil then highestTier.ModelRatio * groupRatioInfo.GroupRatio else
modelRatio * groupRatioInfo.GroupRatio), use effectiveRatio when computing
preConsumedQuota (with preConsumedTokens) and reuse that same effectiveRatio in
the subsequent free-model check instead of modelRatio so the tiered decision is
consistent; update any related flags (hasTieredPricing / tieredModelRatio) only
as before but base free-model logic on effectiveRatio.

} else {
if meta.ImagePriceRatio != 0 {
modelPrice = modelPrice * meta.ImagePriceRatio
Expand Down Expand Up @@ -116,20 +130,24 @@ 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,
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,
HasTieredPricing: hasTieredPricing,
TieredModelRatio: tieredModelRatio,
TieredCompletionRatio: tieredCompletionRatio,
TieredCacheRatio: tieredCacheRatio,
}

if common.DebugEnabled {
Expand Down
18 changes: 18 additions & 0 deletions service/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,21 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
promptTokens -= cacheCreationTokens
}

// Tiered pricing: resolve based on prompt-side tokens
promptSideTokens := promptTokens + cacheTokens + cacheCreationTokens
var tieredTriggered bool
var tieredThreshold int
if relayInfo.PriceData.HasTieredPricing && !relayInfo.PriceData.UsePrice {
tier := ratio_setting.ResolveTieredPricing(relayInfo.OriginModelName, promptSideTokens)
if tier != nil {
modelRatio = tier.ModelRatio
completionRatio = tier.CompletionRatio
cacheRatio = tier.CacheRatio
tieredTriggered = true
tieredThreshold = tier.Threshold
}
}

calculateQuota := 0.0
if !relayInfo.PriceData.UsePrice {
calculateQuota = float64(promptTokens)
Expand All @@ -297,6 +312,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
totalTokens := promptTokens + completionTokens

var logContent string
if tieredTriggered {
logContent += fmt.Sprintf("分段计费已触发(prompt tokens: %dk,阈值: %dk)", promptSideTokens/1000, tieredThreshold/1000)
}
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
Expand Down
1 change: 1 addition & 0 deletions setting/ratio_setting/exposed_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func GetExposedData() gin.H {
"cache_ratio": GetCacheRatioCopy(),
"create_cache_ratio": GetCreateCacheRatioCopy(),
"model_price": GetModelPriceCopy(),
"tiered_pricing": GetTieredPricingCopy(),
}
exposedData.Store(&exposedCache{
data: newData,
Expand Down
1 change: 1 addition & 0 deletions setting/ratio_setting/model_ratio.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ func InitRatioSettings() {
imageRatioMap.AddAll(defaultImageRatio)
audioRatioMap.AddAll(defaultAudioRatio)
audioCompletionRatioMap.AddAll(defaultAudioCompletionRatio)
tieredPricingMap.AddAll(defaultTieredPricing)
}

func GetModelPriceMap() map[string]float64 {
Expand Down
92 changes: 92 additions & 0 deletions setting/ratio_setting/tiered_ratio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package ratio_setting

import (
"github.com/QuantumNous/new-api/types"
)

// TieredPricingTier defines a pricing tier for a model.
// When prompt-side tokens >= Threshold, the ratios in this tier override the base ratios.
// Tiers should be sorted by Threshold in ascending order.
type TieredPricingTier struct {
Threshold int `json:"threshold"`
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
CacheRatio float64 `json:"cache_ratio"`
}

// Default tiered pricing for models that have context-based pricing tiers.
// Key is model name, value is list of tiers (sorted by threshold ascending).
// The first tier (threshold=0) is not needed here — it uses the base ratios from modelRatioMap etc.
// Only tiers above the base need to be listed.
var defaultTieredPricing = map[string][]TieredPricingTier{
// gpt-4.1: base $2/1M input, $8/1M output; 200K+: $4/1M input, $8/1M output (doubled input, same output)
"gpt-4.1": {{Threshold: 200000, ModelRatio: 2.0, CompletionRatio: 2.0, CacheRatio: 0.5}},
"gpt-4.1-2025-04-14": {{Threshold: 200000, ModelRatio: 2.0, CompletionRatio: 2.0, CacheRatio: 0.5}},
"gpt-4.1-mini": {{Threshold: 200000, ModelRatio: 0.4, CompletionRatio: 4.0, CacheRatio: 0.5}},
"gpt-4.1-mini-2025-04-14": {{Threshold: 200000, ModelRatio: 0.4, CompletionRatio: 4.0, CacheRatio: 0.5}},
"gpt-4.1-nano": {{Threshold: 200000, ModelRatio: 0.1, CompletionRatio: 4.0, CacheRatio: 0.5}},
"gpt-4.1-nano-2025-04-14": {{Threshold: 200000, ModelRatio: 0.1, CompletionRatio: 4.0, CacheRatio: 0.5}},
// gpt-5: base $1.25/1M input, $10/1M output; 272K+: $2.5/1M input, $15/1M output
"gpt-5": {{Threshold: 272000, ModelRatio: 1.25, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-2025-08-07": {{Threshold: 272000, ModelRatio: 1.25, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-chat-latest": {{Threshold: 272000, ModelRatio: 1.25, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-mini": {{Threshold: 272000, ModelRatio: 0.25, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-mini-2025-08-07": {{Threshold: 272000, ModelRatio: 0.25, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-nano": {{Threshold: 272000, ModelRatio: 0.05, CompletionRatio: 6.0, CacheRatio: 0.2}},
"gpt-5-nano-2025-08-07": {{Threshold: 272000, ModelRatio: 0.05, CompletionRatio: 6.0, CacheRatio: 0.2}},
}

var tieredPricingMap = types.NewRWMap[string, []TieredPricingTier]()

func TieredPricing2JSONString() string {
return tieredPricingMap.MarshalJSONString()
}

func UpdateTieredPricingByJSONString(jsonStr string) error {
return types.LoadFromJsonStringWithCallback(tieredPricingMap, jsonStr, InvalidateExposedDataCache)
Comment on lines +45 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize and validate tiers before publishing them.

ResolveTieredPricing() and GetHighestTier() both rely on thresholds being strictly ascending, but this accepts arbitrary admin JSON as-is. A misordered or duplicate tier list will select the wrong active tier and can misprice both final settlement and conservative pre-consumption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setting/ratio_setting/tiered_ratio.go` around lines 45 - 46,
UpdateTieredPricingByJSONString must normalize and validate tiers before
publishing: parse the incoming JSON into the same struct used by
tieredPricingMap, sort the tier slice by Threshold ascending, verify thresholds
are strictly increasing (no duplicates) and that required fields (e.g., Name/ID
and Threshold) are present; if validation fails return an error. After
normalization call types.LoadFromJsonStringWithCallback using the normalized
JSON/structure (or replace tieredPricingMap with the normalized value) so
ResolveTieredPricing and GetHighestTier always see a canonical,
strictly-ascending tier list; keep references to tieredPricingMap and
InvalidateExposedDataCache when wiring the call.

}

func GetTieredPricingCopy() map[string][]TieredPricingTier {
return tieredPricingMap.ReadAll()
}

// GetTieredPricing returns the tiered pricing tiers for a model.
// Returns nil if the model has no tiered pricing.
func GetTieredPricing(name string) []TieredPricingTier {
name = FormatMatchingModelName(name)
tiers, ok := tieredPricingMap.Get(name)
if !ok || len(tiers) == 0 {
return nil
}
return tiers
}

// ResolveTieredPricing determines the active tier based on prompt-side token count.
// Returns the tier if prompt tokens exceed a threshold, nil otherwise (use base ratios).
func ResolveTieredPricing(name string, promptSideTokens int) *TieredPricingTier {
tiers := GetTieredPricing(name)
if tiers == nil {
return nil
}

// Find the highest tier whose threshold is <= promptSideTokens
var activeTier *TieredPricingTier
for i := range tiers {
if promptSideTokens >= tiers[i].Threshold {
activeTier = &tiers[i]
} else {
break
}
}
return activeTier
}

// GetHighestTier returns the highest (most expensive) tier for a model.
// Used for conservative pre-consumption estimation.
func GetHighestTier(name string) *TieredPricingTier {
tiers := GetTieredPricing(name)
if tiers == nil {
return nil
}
return &tiers[len(tiers)-1]
}
4 changes: 4 additions & 0 deletions types/price_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type PriceData struct {
Quota int // 按次计费的最终额度(MJ / Task)
QuotaToPreConsume int // 按量计费的预消耗额度
GroupRatioInfo GroupRatioInfo
HasTieredPricing bool // 是否启用了分段计费
TieredModelRatio float64 // 分段计费时的模型倍率(覆盖 ModelRatio)
TieredCompletionRatio float64 // 分段计费时的补全倍率(覆盖 CompletionRatio)
TieredCacheRatio float64 // 分段计费时的缓存倍率(覆盖 CacheRatio)
}

func (p *PriceData) AddOtherRatio(key string, ratio float64) {
Expand Down
1 change: 1 addition & 0 deletions web/src/components/settings/RatioSetting.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const RatioSetting = () => {
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
TieredPricing: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
Expand Down
Loading