Files
Seefs a073f74b38 refactor: deprecate int32 (#7025)
* refactor: deprecate int32

* fix(db): reject legacy user quota schemas at startup

* fix(quota): enforce wallet bounds and saturating billing conversions

* fix(rate-limit): keep count*duration from wrapping int64

* fix: error message
2026-08-26 20:57:54 +08:00

190 lines
5.9 KiB
Go

package service
import (
"net/http"
"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/types"
"github.com/gin-gonic/gin"
)
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
type TieredResultWrapper = billingexpr.TieredResult
// BuildTieredTokenParams constructs billingexpr.TokenParams from a dto.Usage,
// normalizing P and C so they mean "tokens not separately priced by the
// expression". Sub-categories (cache, image, audio) are only subtracted
// when the expression references them via their own variable.
//
// GPT-format APIs report prompt_tokens / completion_tokens as totals that
// include all sub-categories (cache, image, audio). Claude-format APIs
// report them as text-only. This function normalizes to text-only when
// sub-categories are separately priced.
func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVars map[string]bool) billingexpr.TokenParams {
p := float64(usage.PromptTokens)
c := float64(usage.CompletionTokens)
cr := float64(usage.PromptTokensDetails.CachedTokens)
cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal())
cc1h := float64(0)
if usage.UsageSemantic == "anthropic" {
cc1h = float64(usage.ClaudeCacheCreation1hTokens)
cc5m = float64(usage.ClaudeCacheCreation5mTokens)
}
img := float64(usage.PromptTokensDetails.ImageTokens)
ai := float64(usage.PromptTokensDetails.AudioTokens)
imgO := float64(usage.CompletionTokenDetails.ImageTokens)
ao := float64(usage.CompletionTokenDetails.AudioTokens)
// len = total input context length for tier condition evaluation.
// Non-Claude: prompt_tokens already includes everything.
// Claude: input_tokens is text-only, so add cache read + cache creation.
inputLen := p
if isClaudeUsageSemantic {
inputLen = p + cr + cc5m + cc1h
}
if !isClaudeUsageSemantic {
if usedVars["cr"] {
p -= cr
}
if usedVars["cc"] {
p -= cc5m
}
if usedVars["cc1h"] {
p -= cc1h
}
if usedVars["img"] {
p -= img
}
if usedVars["ai"] {
p -= ai
}
if usedVars["img_o"] {
c -= imgO
}
if usedVars["ao"] {
c -= ao
}
}
// OpenAI cache-write usage reports unadjusted prefix counts, so cr + cc can
// exceed the prompt and drive the remainder negative. Clamp at zero.
if p < 0 {
p = 0
}
if c < 0 {
c = 0
}
return billingexpr.TokenParams{
P: p,
C: c,
Len: inputLen,
CR: cr,
CC: cc5m,
CC1h: cc1h,
Img: img,
ImgO: imgO,
AI: ai,
AO: ao,
}
}
func refreshTieredBillingGroup(relayInfo *relaycommon.RelayInfo) (*billingexpr.BillingSnapshot, error) {
if relayInfo == nil {
return nil, nil
}
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return nil, nil
}
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
if snap.GroupRatio == groupRatio {
return snap, nil
}
estimatedQuotaAfterGroup := snap.EstimatedQuotaBeforeGroup * groupRatio
estimatedQuota, err := billingexpr.QuotaRoundStrict(estimatedQuotaAfterGroup)
if err != nil {
return nil, err
}
snap.GroupRatio = groupRatio
snap.EstimatedQuotaAfterGroup = estimatedQuota
return snap, nil
}
// PrepareTieredBillingForSelectedGroup refreshes routing-dependent billing
// state before an upstream attempt. An existing session reserves any higher
// estimate before sending. If the initial group was free and skipped
// pre-consume, switching to a paid group creates the session at that point.
func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
snap, err := refreshTieredBillingGroup(relayInfo)
if err != nil {
return types.NewErrorWithStatusCode(
err,
types.ErrorCodeModelPriceError,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
if snap == nil {
return nil
}
if snap.GroupRatio == 0 {
// Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
// skipped", which is not true once a session exists, and settlement
// already yields 0 for a zero group ratio.
return nil
}
// The selected group is paid; clear a FreeModel flag frozen when the
// initial group was free so downstream state stays consistent.
relayInfo.PriceData.FreeModel = false
if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
}
if err := relayInfo.Billing.Reserve(snap.EstimatedQuotaAfterGroup); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
relayInfo.FinalPreConsumedQuota = relayInfo.Billing.GetPreConsumedQuota()
return nil
}
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the captured BillingSnapshot. Returns:
// - ok=true, quota, result when tiered billing applies
// - ok=false, 0, nil when it doesn't (caller should fall through to existing logic)
func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenParams) (ok bool, quota int, result *billingexpr.TieredResult) {
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return false, 0, nil
}
requestInput := billingexpr.RequestInput{}
if relayInfo.BillingRequestInput != nil {
requestInput = *relayInfo.BillingRequestInput
}
tr, err := billingexpr.ComputeTieredQuotaWithRequest(snap, params, requestInput)
if err != nil {
quota = relayInfo.FinalPreConsumedQuota
if quota <= 0 {
quota = snap.EstimatedQuotaAfterGroup
}
return true, quota, nil
}
// Surface any single-request saturation from settlement onto RelayInfo so the
// consume log records it under admin_info, regardless of which caller
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
noteQuotaClamp(relayInfo, tr.Clamp)
return true, tr.ActualQuotaAfterGroup, &tr
}