diff --git a/model/option.go b/model/option.go index 697e77dfe7a5..95f359b33bf6 100644 --- a/model/option.go +++ b/model/option.go @@ -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 @@ -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": diff --git a/model/pricing.go b/model/pricing.go index cb687d04ad1f..c644a731dc24 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -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 { @@ -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) } diff --git a/relay/helper/price.go b/relay/helper/price.go index f109040da0ed..7913239e2188 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -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 { @@ -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) + } } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio @@ -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 { diff --git a/service/quota.go b/service/quota.go index 7ee70edd50c1..0dde4dbdf30d 100644 --- a/service/quota.go +++ b/service/quota.go @@ -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) @@ -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 diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index c88216fcb015..cb62211d7552 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(), + "tiered_pricing": GetTieredPricingCopy(), } exposedData.Store(&exposedCache{ data: newData, diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index a40fb35f0064..bbf48590681e 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) + tieredPricingMap.AddAll(defaultTieredPricing) } func GetModelPriceMap() map[string]float64 { diff --git a/setting/ratio_setting/tiered_ratio.go b/setting/ratio_setting/tiered_ratio.go new file mode 100644 index 000000000000..3a42fca15f71 --- /dev/null +++ b/setting/ratio_setting/tiered_ratio.go @@ -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) +} + +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] +} diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..98a4015056f2 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -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) { diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx index 170413058c1e..0ae9b6fac56f 100644 --- a/web/src/components/settings/RatioSetting.jsx +++ b/web/src/components/settings/RatioSetting.jsx @@ -43,6 +43,7 @@ const RatioSetting = () => { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + TieredPricing: '', AutoGroups: '', DefaultUseAutoGroup: false, ExposeRatioEnabled: false, diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index 266bbf9478c2..38eb2973850c 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -181,36 +181,145 @@ const ModelPricingTable = ({ ); }; - return ( - -
- - - -
- {t('分组价格')} -
- {t('不同用户分组的价格信息')} + const formatTokenCount = (count) => { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(0)}K`; + return String(count); + }; + + const renderTieredPricing = () => { + const tiers = modelData?.tiered_pricing; + if (!tiers || tiers.length === 0 || modelData?.quota_type !== 0) return null; + + // Build tier ranges: base tier + defined tiers + const baseModelRatio = modelData.model_ratio || 0; + const baseCompletionRatio = modelData.completion_ratio || 1; + const allTiers = []; + + // Determine the first tier's upper bound + const firstThreshold = tiers[0]?.threshold || 0; + allTiers.push({ + label: `0 - ${formatTokenCount(firstThreshold)}`, + modelRatio: baseModelRatio, + completionRatio: baseCompletionRatio, + }); + + tiers.forEach((tier, idx) => { + const nextThreshold = idx < tiers.length - 1 ? tiers[idx + 1].threshold : null; + const label = nextThreshold + ? `${formatTokenCount(tier.threshold + 1)} - ${formatTokenCount(nextThreshold)}` + : `${formatTokenCount(tier.threshold + 1)}+`; + allTiers.push({ + label, + modelRatio: tier.model_ratio, + completionRatio: tier.completion_ratio, + }); + }); + + // Use the first available group ratio + let usedGroupRatio = 1; + const availableGroups = Object.keys(usableGroup || {}) + .filter((g) => g !== '' && g !== 'auto') + .filter((g) => modelEnableGroups.includes(g)); + if (availableGroups.length > 0) { + const firstGroup = availableGroups[0]; + usedGroupRatio = groupRatio?.[firstGroup] ?? 1; + } + + let symbol = '$'; + if (currency === 'CNY') symbol = '¥'; + + const unitDivisor = tokenUnit === 'K' ? 1000 : 1; + const unitLabel = tokenUnit === 'K' ? '1K' : '1M'; + + return ( + +
+ + + +
+ {t('分段计费')} +
+ {t('基于 prompt 侧 token 数量')} +
-
- {autoChain.length > 0 && ( -
- {t('auto分组调用链路')} - - {autoChain.map((g, idx) => ( - - - {g} - {t('分组')} - - {idx < autoChain.length - 1 && } - - ))} +
+ {allTiers.map((tier, idx) => { + const inputPriceUSD = tier.modelRatio * 2 * usedGroupRatio; + const outputPriceUSD = tier.modelRatio * tier.completionRatio * 2 * usedGroupRatio; + const numInput = (currency === 'CNY' ? inputPriceUSD * 7.3 : inputPriceUSD) / unitDivisor; + const numOutput = (currency === 'CNY' ? outputPriceUSD * 7.3 : outputPriceUSD) / unitDivisor; + + return ( +
0 ? 'var(--semi-color-warning-light-default)' : 'var(--semi-color-fill-0)', + }} + > +
+ {tier.label} + per {unitLabel} tokens +
+
+
+
{t('输入')}
+
+ {symbol}{numInput.toFixed(4)} +
+
+
+
{t('输出')}
+
+ {symbol}{numOutput.toFixed(4)} +
+
+
+
+ ); + })}
- )} - {renderGroupPriceTable()} - + + ); + }; + + return ( + <> + +
+ + + +
+ {t('分组价格')} +
+ {t('不同用户分组的价格信息')} +
+
+
+ {autoChain.length > 0 && ( +
+ {t('auto分组调用链路')} + + {autoChain.map((g, idx) => ( + + + {g} + {t('分组')} + + {idx < autoChain.length - 1 && } + + ))} +
+ )} + {renderGroupPriceTable()} +
+ {renderTieredPricing()} + ); }; diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 6d532869f652..b876b068b6ed 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -172,6 +172,14 @@ const PricingCardView = ({ ); } + // 分段计费标签 + const hasTiered = record.tiered_pricing && record.tiered_pricing.length > 0; + const tieredTag = hasTiered ? ( + + {t('分段计费')} + + ) : null; + // 自定义标签(右边) const customTags = []; if (record.tags) { @@ -192,7 +200,7 @@ const PricingCardView = ({ return (
-
{billingTag}
+
{billingTag}{tieredTag}
{customTags.length > 0 && renderLimitedItems({ diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index e9be19785547..5a4ef44fd370 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -48,6 +48,7 @@ export default function ModelRatioSettings(props) { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + TieredPricing: '', ExposeRatioEnabled: false, }); const refForm = useRef(); @@ -319,6 +320,32 @@ export default function ModelRatioSettings(props) { /> + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, TieredPricing: value }) + } + /> + +