Files

431 lines
15 KiB
Go

package helper
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
func modelPriceNotConfiguredError(c *gin.Context, modelName string, userId int) error {
key := i18n.MsgRelayModelPriceNotConfigured
if model.IsAdmin(userId) {
key = i18n.MsgRelayModelPriceNotConfiguredAdmin
}
return errors.New(i18n.T(c, key, map[string]any{"Model": modelName}))
}
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
const claudeCacheCreation1hMultiplier = 6 / 3.75
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hosttypes.GroupRatioInfo {
if autoGroup, exists := ctx.Get("auto_group"); exists {
logger.LogDebug(ctx, "final group: %s", autoGroup)
relayInfo.UsingGroup = autoGroup.(string)
}
return model.EffectiveGroupRatio(ctx, relayInfo.UserGroup, relayInfo.UsingGroup)
}
func deepSeekOfficialGroupRatio(info *relaycommon.RelayInfo, ratio hosttypes.GroupRatioInfo) hosttypes.GroupRatioInfo {
if info == nil || info.UsingGroup != billing_setting.DeepSeekOfficialGroup {
return ratio
}
return hosttypes.GroupRatioInfo{
GroupRatio: billing_setting.DeepSeekOfficialGroupRatio,
GroupSpecialRatio: -1,
}
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
if info != nil {
if matched := resolveBillingModelName(info.GetOriginModelName()); matched != "" && matched != info.OriginModelName {
info.BillingModelName = matched
}
}
billingModelName := info.GetBillingModelName()
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
groupRatioInfo := HandleGroupRatio(c, info)
if info.UsingGroup == billing_setting.DeepSeekOfficialGroup {
if _, ok := billing_setting.GetGroupBillingExpr(info.UsingGroup, billingModelName); ok {
groupRatioInfo = deepSeekOfficialGroupRatio(info, groupRatioInfo)
}
}
// Check if this model uses tiered_expr billing
_, hasGroupExpr := billing_setting.GetGroupBillingExpr(info.UsingGroup, billingModelName)
if hasGroupExpr || billing_setting.GetBillingMode(billingModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, billingModelName, promptTokens, groupRatioInfo)
}
var preConsumedQuota int
var modelRatio float64
var completionRatio float64
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
var cacheCreationRatio5m float64
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64
var freeModel bool
if !usePrice {
preConsumeMultiplier, err := operation_setting.InputPreConsumeMultiplier()
if err != nil {
return hosttypes.PriceData{}, err
}
preConsumedTokens := float64(promptTokens) * preConsumeMultiplier
var success bool
var matchName string
modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
if !success {
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
return hosttypes.PriceData{}, modelPriceNotConfiguredError(c, matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(billingModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(billingModelName)
audioRatio = ratio_setting.GetAudioRatio(billingModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
quota, err := common.QuotaFromFloatStrict(preConsumedTokens * ratio)
if err != nil {
return hosttypes.PriceData{}, err
}
preConsumedQuota = quota
if _, image := info.Request.(*dto.ImageRequest); image {
info.ImageQuotaBeforeGroup = preConsumedTokens * modelRatio
}
} else {
if meta.ImagePriceRatio != 0 {
modelPrice = modelPrice * meta.ImagePriceRatio
}
if _, image := info.Request.(*dto.ImageRequest); image {
info.ImageQuotaBeforeGroup = modelPrice * common.QuotaPerUnit
}
}
// check if free model pre-consume is disabled
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
// if model price or ratio is 0, do not pre-consume quota
if groupRatioInfo.GroupRatio == 0 {
preConsumedQuota = 0
freeModel = true
} else if usePrice {
if modelPrice == 0 {
preConsumedQuota = 0
freeModel = true
}
} else {
if modelRatio == 0 {
preConsumedQuota = 0
freeModel = true
}
}
}
priceData := hosttypes.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,
}
if usePrice {
for name, ratio := range meta.BillingRatios {
priceData.AddOtherRatio(name, ratio)
}
}
if request, image := info.Request.(*dto.ImageRequest); image {
count, err := request.ImageCount(false)
if err != nil {
return hosttypes.PriceData{}, err
}
if usePrice {
priceData.AddOtherRatio("n", float64(count))
}
if !usePrice {
quota, err := common.QuotaFromFloatStrict(priceData.ApplyOtherRatiosToFloat(info.ImageQuotaBeforeGroup * groupRatioInfo.GroupRatio))
if err != nil {
return hosttypes.PriceData{}, err
}
priceData.QuotaToPreConsume = quota
}
}
if usePrice {
quotaToPreConsume := priceData.ApplyOtherRatiosToFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
quota, err := common.QuotaFromFloatStrict(quotaToPreConsume)
if err != nil {
return hosttypes.PriceData{}, err
}
priceData.QuotaToPreConsume = quota
}
if common.DebugEnabled {
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
}
info.PriceData = priceData
return priceData, nil
}
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (hosttypes.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
usePrice := success
var modelRatio float64
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !ratioSuccess && !acceptUnsetRatio {
return hosttypes.PriceData{}, modelPriceNotConfiguredError(c, matchName, info.UserId)
}
}
}
var quota int
freeModel := false
if usePrice {
var err error
quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if err != nil {
return hosttypes.PriceData{}, err
}
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
var err error
quota, err = common.QuotaFromFloatStrict(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if err != nil {
return hosttypes.PriceData{}, err
}
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := hosttypes.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
return priceData, nil
}
func HasModelBillingConfig(modelName string) bool {
if _, ok := ratio_setting.GetModelPrice(modelName, false); ok {
return true
}
if _, ok, _ := ratio_setting.GetModelRatio(modelName); ok {
return true
}
if billing_setting.GetBillingMode(modelName) != billing_setting.BillingModeTieredExpr {
return false
}
expr, ok := billing_setting.GetBillingExpr(modelName)
return ok && strings.TrimSpace(expr) != ""
}
// HasPriceOrRatioEntry reports whether name has a configured price, ratio, or
// tiered billing-mode entry after a single wildcard normalization. Self-use
// fallback does not count as a configured ratio.
func HasPriceOrRatioEntry(name string) bool {
formatted := ratio_setting.FormatMatchingModelName(name)
if _, ok := ratio_setting.GetModelPrice(formatted, false); ok {
return true
}
if ratio_setting.HasConfiguredModelRatio(formatted) {
return true
}
return billing_setting.GetBillingMode(formatted) == billing_setting.BillingModeTieredExpr
}
func resolveBillingModelName(origin string) string {
var candidates []string
if !reasoning.ParseModelModifiers(origin).HasModifiers() {
candidates = append(candidates, origin)
}
candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...)
base := hostreasoning.BaseModelName(origin)
// DeepSeek thinking tails (-none/-low/-max) bill at the base model's price,
// with or without @ modifiers after them.
if deepSeekBase, _, _, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(base); ok && !model_setting.ShouldPreserveThinkingSuffix(base) {
candidates = append(candidates, deepSeekBase)
}
candidates = append(candidates, base)
seen := make(map[string]struct{}, len(candidates))
matched := ""
for _, name := range candidates {
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
if HasPriceOrRatioEntry(name) {
matched = name
break
}
}
if matched == "" {
matched = base
}
return matched
}
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExprForGroup(info.UsingGroup, billingModelName)
if !ok {
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
}
return modelPriceHelperTieredWithExpr(c, info, billingModelName, promptTokens, groupRatioInfo, exprStr)
}
func modelPriceHelperTieredWithExpr(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, groupRatioInfo hosttypes.GroupRatioInfo, exprStr string) (hosttypes.PriceData, error) {
exprHash := billingexpr.ExprHashString(exprStr)
if info.RelayFormat == types.RelayFormatOpenAIRealtime && billingexpr.UsesFixedPricingByHash(exprStr, exprHash) {
return hosttypes.PriceData{}, fmt.Errorf("fixed pricing is not supported for Realtime requests")
}
preConsumeMultiplier, err := operation_setting.InputPreConsumeMultiplier()
if err != nil {
return hosttypes.PriceData{}, err
}
requestInput, err := ResolveIncomingBillingExprRequestInput(c, info)
if err != nil {
return hosttypes.PriceData{}, err
}
if billingexpr.UsedVarsByHash(exprStr, exprHash)["image_count"] {
requestInput, err = ResolveImageBillingRequestInput(c, info, requestInput)
if err != nil {
return hosttypes.PriceData{}, err
}
}
rawCost, trace, err := billingexpr.RunExprByHashWithRequest(exprStr, exprHash, billingexpr.TokenParams{
P: float64(promptTokens),
C: 0,
Len: float64(promptTokens),
}, requestInput)
if err != nil {
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", billingModelName, err)
}
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit
// Scale the reservation, preserving the expression's context-length tier
// and leaving actual settlement and fixed request prices unchanged.
if trace.BillingUnit != billingexpr.BillingUnitRequest {
quotaBeforeGroup *= preConsumeMultiplier
}
preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio)
if err != nil {
return hosttypes.PriceData{}, err
}
freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 {
preConsumedQuota = 0
freeModel = true
}
}
snapshot := &billingexpr.BillingSnapshot{
EstimatedImageCount: trace.ImageCount,
BillingMode: billing_setting.BillingModeTieredExpr,
ModelName: billingModelName,
ExprString: exprStr,
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
EstimatedPromptTokens: promptTokens,
EstimatedCompletionTokens: 0,
PreConsumeMultiplier: preConsumeMultiplier,
EstimatedQuotaBeforeGroup: quotaBeforeGroup,
EstimatedQuotaAfterGroup: preConsumedQuota,
EstimatedTier: trace.MatchedTier,
EstimatedBillingUnit: trace.BillingUnit,
EstimatedFixedPrice: trace.FixedPrice,
QuotaPerUnit: common.QuotaPerUnit,
ExprVersion: billingexpr.ExprVersion(exprStr),
}
info.TieredBillingSnapshot = snapshot
info.BillingRequestInput = &requestInput
priceData := hosttypes.PriceData{
FreeModel: freeModel,
GroupRatioInfo: groupRatioInfo,
QuotaToPreConsume: preConsumedQuota,
}
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", billingModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
info.PriceData = priceData
return priceData, nil
}