Follow-up to #6518 (issue #6480) addressing three review findings: - Document and lock in arrears semantics for the wallet Reserve top-up: when an auto-group retry lands on a more expensive group, the full reservation delta is deducted unconditionally (balance may go negative), mirroring settlement, so the logged pre-consumed quota always reconciles with the actual balance movement. Genuine DB errors still fail the attempt with update_data_error. Subscription funding keeps its insufficient-quota behavior: subscriptions enforce a hard used<=total cap and do not support arrears. - PriceData.FreeModel is cleared when a retry switches from a free group to a paid one, keeping it consistent with the billing session created at that point. - getChannel refreshes GroupRatioInfo only after channel selection succeeds, and the retry loop records the channel in use_channel before PrepareTieredBillingForSelectedGroup can fail.
190 lines
5.9 KiB
Go
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 int32 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
|
|
}
|